ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

通达信公式转Python量化实战:技术指标计算与向量化改写指南

通达信公式转Python量化实战:技术指标计算与向量化改写指南

1. 为什么要把通达信公式搬到Python里

做过A股量化的人多半都经历过这个阶段:在通达信里用公式管理器写了一套自认为还不错的技术指标或者选股条件,回测看着挺美,但一旦想接入实盘、想批量跑全市场、想和机器学习模型结合,就发现通达信那套公式语言(TDX Formula)的边界太窄了。它没有真正的循环控制,没有复杂数据结构,跨品种批量计算效率低,更别提和外部数据源、风控模块、下单接口打通了。

把通达信公式改写成Python代码,本质上解决的是策略从"看图工具"走向"可编程系统"的问题。通达信公式适合快速验证想法,Python适合工程化落地。这两者之间需要一个翻译层,而这个翻译层不是简单的语法替换,而是对数据对齐方式、计算语义、边界条件的重新理解。

这篇文章适合三类人看:一是有通达信公式基础、想转Python量化的朋友;二是已经在用Python做量化、但需要把别人给的TDX公式快速移植过来的开发者;三是想理解技术指标底层计算逻辑、不想被黑盒函数糊弄的进阶玩家。我会从公式语言的核心语义讲起,逐条拆解改写方法,给出可直接复用的代码模板,最后把我踩过的坑一次性倒出来。

核心关键词:通达信公式改写、Python量化、技术指标计算、向量化、数据对齐。这些词会贯穿全文,你如果正在做这件事,建议边看边对着自己的公式试。

2. 通达信公式语言的核心语义拆解

2.1 通达信公式到底在算什么

很多人写通达信公式写了好几年,但其实没想清楚它的执行模型。通达信公式的本质是基于时间序列的向量运算。每一根K线对应一个数据点,公式里的变量默认都是长度等于K线数量的数组。你写CLOSE,拿到的是整个收盘价序列;你写MA(CLOSE, 5),拿到的是每个位置上前5根K线收盘价的均值序列。

这一点和Python里用pandas的Series做向量化运算是一一对应的。理解了这个对应关系,改写就有了主线:通达信的每个变量名,对应Python里的一个Series或ndarray。

但通达信有个特殊之处:它区分序列变量和即时变量。序列变量用:=赋值,会保留历史值;即时变量用:赋值,只在当前K线有效。这个区别在改写时经常被忽略,导致结果对不上。

2.2 常见函数分类与Python对应关系

我把通达信常用函数分成几类,改写时按类处理效率最高:

通达信函数功能Python对应实现
MA, EMA, SMA移动平均pandas的rolling().mean() / ewm()
REF(X, N)引用N周期前的值Series.shift(N)
HHV, LLVN周期最高/最低rolling().max() / rolling().min()
CROSS(A, B)A上穿B(A>B) & (A.shift(1)<=B.shift(1))
COUNT(X, N)N周期内满足条件次数rolling(N).sum()
SUM(X, N)N周期求和rolling(N).sum()
IF(X, A, B)条件选择numpy.where(X, A, B)
BARSLAST(X)上次条件成立到现在的周期数需要手写循环或技巧实现

这张表是改写的核心字典。你会发现大部分函数都能用pandas的rolling和shift组合出来,但BARSLAST、BARSCOUNT这类涉及"距离上次事件"的函数,pandas没有直接对应,需要特殊处理。

2.3 改写前必须搞清楚的三个语义差异

第一个差异是数据对齐。通达信默认按交易日连续排列,停牌日会被跳过或填充。而Python里如果你用pandas读数据,索引可能是自然日也可能是交易日,rolling窗口的计算基准完全不同。我见过太多人改写的MA结果和通达信差一两个点,就是因为索引没对齐。

第二个差异是空值处理。通达信在数据不足N根K线时,MA返回的是无效值,后续计算会跳过。pandas的rolling默认前N-1个是NaN,但如果你用了min_periods参数不当,会得到错误结果。改写时建议保持min_periods=N,和通达信行为一致。

第三个差异是未来函数。通达信里有些写法(比如用REF引用未来数据)在回测时会偷看未来,Python里如果不注意shift方向,同样会犯这个错。改写时凡是涉及REF(X, -N)这种负向引用的,都要警惕。

提示:改写前先把原公式在通达信里跑一遍,导出几组关键数值作为基准,Python改完后逐一对齐。这是最笨但最可靠的方法。

3. 逐类拆解:从TDX语法到Python实现

3.1 移动平均类函数的精确改写

先看最简单的MA。通达信里MA(CLOSE, 5),Python写法:

import pandas as pd import numpy as np df['ma5'] = df['close'].rolling(window=5, min_periods=5).mean()

看起来简单,但有个细节:通达信的MA是简单移动平均,不是指数移动平均。很多人把EMA和MA搞混。EMA的改写要用ewm:

df['ema12'] = df['close'].ewm(span=12, adjust=False).mean()

这里adjust=False是关键。pandas的ewm默认adjust=True,计算方式和通达信的EMA不同。通达信EMA的递推公式是:EMA_today = EMA_yesterday * (N-1)/(N+1) + CLOSE * 2/(N+1)。用adjust=False才能得到一致结果。

至于SMA,通达信的SMA(X, N, M)是加权移动平均,公式为SMA_today = (M*X + (N-M)*SMA_yesterday) / N。这个pandas没有直接对应,需要手写:

def tdx_sma(series, n, m): result = np.zeros(len(series)) result[:] = np.nan # 找到第一个有效值作为起点 for i in range(len(series)): if i == 0: result[i] = series[i] else: result[i] = (m * series[i] + (n - m) * result[i-1]) / n return pd.Series(result, index=series.index)

注意这个函数用了循环,效率不高。如果数据量大,可以用numba加速,或者用递推公式的解析解做向量化。我实测过,100万根K线用纯Python循环大概要几秒,用numba能压到毫秒级。

3.2 引用与位移:REF和SHIFT的坑

REF(CLOSE, 1)就是昨天的收盘价,Python里是df['close'].shift(1)。但这里有个大坑:shift的方向。pandas的shift(1)是把数据往下移一行,也就是当前行拿到的是上一行的值,这正好对应REF的正向引用。而shift(-1)是往上移,拿到的是未来数据,对应REF的负向引用。

我见过有人写REF(CLOSE, 1)改成shift(-1),结果回测收益高得离谱,一查是偷看了未来。记住:REF正数用shift正数,REF负数用shift负数,但负数是未来函数,实盘不能用。

还有一个细节:通达信的REF在数据不足时返回无效值,pandas的shift默认填充NaN,行为一致。但如果你用了fillna,要小心别把无效值填成0,那会污染后续计算。

3.3 条件判断与逻辑运算的向量化

通达信里IF(CLOSE > OPEN, 1, 0),Python里最直接的是:

df['signal'] = np.where(df['close'] > df['open'], 1, 0)

但更pandas的写法是:

df['signal'] = (df['close'] > df['open']).astype(int)

两种都行,前者更接近通达信语义,后者更快。我一般用后者,因为布尔转整数是零拷贝的。

CROSS函数是改写的高频难点。通达信CROSS(A, B)表示A上穿B,即前一周期A<=B且当前周期A>B。Python实现:

def cross_up(a, b): return (a > b) & (a.shift(1) <= b.shift(1))

注意这里用了&而不是and,因为是对Series做逐元素运算。and只能用于标量,用错了会报错。这是新手最常见的错误之一。

3.4 周期统计类:HHV、LLV、COUNT、SUM

这四个函数都是滚动窗口统计,pandas的rolling能直接搞定:

df['hhv20'] = df['high'].rolling(20).max() df['llv20'] = df['low'].rolling(20).min() df['count_up'] = (df['close'] > df['open']).rolling(10).sum() df['sum_vol'] = df['volume'].rolling(5).sum()

但要注意:通达信的HHV(X, N)在N为0时表示从第一根K线到当前的最高值,是扩展窗口而不是固定窗口。Python里要用expanding():

df['hhv_all'] = df['high'].expanding().max()

这个细节如果不注意,改写出来的结果在早期数据上会差很多。

3.5 最难啃的骨头:BARSLAST和BARSCOUNT

BARSLAST(X)返回上一次X为真到现在的周期数。这个函数pandas没有直接对应,我试过三种实现方式:

方式一:纯循环,最直观但最慢:

def barslast(condition): result = np.full(len(condition), np.nan) last_true = -1 for i, val in enumerate(condition): if val: last_true = i if last_true >= 0: result[i] = i - last_true return pd.Series(result, index=condition.index)

方式二:用groupby技巧,速度快但可读性差:

def barslast_fast(condition): cond = condition.astype(int) groups = cond.cumsum() # 在每个组内做累积计数 return cond.groupby(groups).cumcount()

方式三:用numba加速循环,兼顾速度和可读性。我一般推荐方式三,因为量化策略里BARSLAST用得很多,性能不能忽视。

BARSCOUNT类似,返回的是从第一根有效K线到当前的周期数,可以用np.arange配合条件筛选实现。

4. 完整实战:把一个真实选股公式改写成Python

4.1 原始公式解析

假设有这样一个通达信选股公式(我做了脱敏处理,这是常见形态):

N:=20; M:=60; 均线多头:=MA(CLOSE,5)>MA(CLOSE,10) AND MA(CLOSE,10)>MA(CLOSE,N); 放量:=VOL>MA(VOL,5)*1.5; 突破:=CROSS(CLOSE,HHV(HIGH,N)); 回调:=CLOSE/REF(CLOSE,1)<1.03; 选股:均线多头 AND 放量 AND 突破 AND 回调;

这个公式的逻辑是:均线多头排列 + 放量 + 突破20日高点 + 当日涨幅不超过3%。改写前先逐行拆解语义。

4.2 逐行改写与参数计算

第一步,准备数据。假设你已经有了一个DataFrame,包含open、high、low、close、volume,索引是交易日:

import pandas as pd import numpy as np # 假设df已经按日期升序排列 df = df.sort_index() N = 20 M = 60

第二步,计算均线:

df['ma5'] = df['close'].rolling(5, min_periods=5).mean() df['ma10'] = df['close'].rolling(10, min_periods=10).mean() df['ma20'] = df['close'].rolling(N, min_periods=N).mean() df['均线多头'] = (df['ma5'] > df['ma10']) & (df['ma10'] > df['ma20'])

第三步,放量判断:

df['vol_ma5'] = df['volume'].rolling(5, min_periods=5).mean() df['放量'] = df['volume'] > df['vol_ma5'] * 1.5

第四步,突破判断。这里HHV(HIGH, N)是20日最高价,CROSS是上穿:

df['hhv20'] = df['high'].rolling(N, min_periods=N).max() df['突破'] = (df['close'] > df['hhv20']) & (df['close'].shift(1) <= df['hhv20'].shift(1))

注意这里有个细节:通达信的CROSS(CLOSE, HHV(HIGH,N)),HHV包含了当日最高价,所以当日收盘价要超过当日最高价是不可能的。实际上这个写法在通达信里也有点问题,更合理的应该是用REF(HHV(HIGH,N),1)。但既然原公式这么写,改写时就保持一致,只是要意识到这个逻辑可能选不出票。

第五步,回调判断:

df['回调'] = df['close'] / df['close'].shift(1) < 1.03

第六步,组合信号:

df['选股'] = df['均线多头'] & df['放量'] & df['突破'] & df['回调']

4.3 结果对齐与验证方法

改完后怎么验证?我的做法是:在通达信里把公式加载到副图,导出最近100个交易日的选股信号,然后在Python里输出同样区间的信号,逐日对比。

对比时重点看三类差异:一是边界日期(数据不足N根K线时),二是停牌日前后,三是除权除息日。这三类是最容易出问题的地方。

如果发现信号数量对不上,先检查rolling的min_periods设置,再检查shift的方向,最后检查逻辑运算符用的是&还是and。90%的问题出在这三处。

提示:验证时不要只看最终信号,要把中间变量(均线多头、放量、突破、回调)逐个对比。这样能快速定位是哪一步出了问题。

5. 性能优化:让改写后的代码跑得比通达信还快

5.1 向量化优先原则

Python量化最大的性能陷阱就是在循环里做逐行计算。我见过有人把通达信公式改写成for循环遍历每一根K线,结果跑全市场5000只股票要几个小时。而用pandas向量化,同样的任务几分钟就能跑完。

原则很简单:能用rolling、shift、ewm解决的,绝不用循环。只有BARSLAST这类无法向量化的函数才用循环,而且要用numba加速。

5.2 用numba加速无法向量化的部分

对于BARSLAST、SMA这类需要递推的函数,numba是最佳选择:

from numba import njit @njit def barslast_numba(condition): n = len(condition) result = np.full(n, np.nan) last_true = -1 for i in range(n): if condition[i]: last_true = i if last_true >= 0: result[i] = i - last_true return result

第一次调用会有编译开销,之后每次调用都是机器码速度。我实测过,100万根K线的BARSLAST,纯Python循环要2秒左右,numba只要5毫秒。

5.3 批量处理多只股票的技巧

全市场选股时,不要一只一只股票循环调用公式函数。更好的做法是把所有股票数据拼成一个大DataFrame,用groupby做分组计算:

# df包含code列 df = df.sort_values(['code', 'date']) df['ma5'] = df.groupby('code')['close'].transform( lambda x: x.rolling(5, min_periods=5).mean() )

这样pandas会在底层做优化,比外层循环快很多。如果数据量特别大,可以考虑用polars替代pandas,速度能再提升几倍。

5.4 内存与计算效率的平衡

rolling窗口越大,内存占用越高。如果只是做选股,不需要保留所有中间变量,可以用del及时释放:

df['ma5'] = df['close'].rolling(5).mean() df['ma10'] = df['close'].rolling(10).mean() # 用完就删 del df['ma5'], df['ma10']

另外,数据类型也很关键。默认的float64占8字节,如果精度要求不高,可以转成float32,内存直接减半。成交量用int32或int64,不要用float。

6. 常见问题与排查技巧实录

6.1 改写后结果对不上的排查清单

现象可能原因排查方法
均线值差一点min_periods设置不对检查rolling的min_periods是否等于窗口
信号数量多很多逻辑运算符用错确认用&而非and,用`
早期数据全是NaN窗口数据不足通达信会跳过,pandas默认NaN,行为一致
突破信号提前出现shift方向反了检查REF正负和shift正负的对应
EMA结果不一致adjust参数没设ewm必须加adjust=False
停牌日前后异常索引未对齐确认索引是交易日且无缺失

这张表是我自己踩坑总结的,基本上覆盖了90%的改写问题。遇到对不上的情况,按表逐项排查,比盲目调试快得多。

6.2 除权除息导致的数据断裂

通达信默认使用前复权数据,而很多Python数据源默认是不复权或后复权。如果不做处理,改写后的均线、突破信号会完全对不上。

解决办法:在数据准备阶段统一做前复权处理。前复权的逻辑是:以最新价格为基准,把历史价格按除权因子调整。具体公式是:

# 假设有除权因子adjust_factor df['close_adj'] = df['close'] * df['adjust_factor'] df['open_adj'] = df['open'] * df['adjust_factor'] df['high_adj'] = df['high'] * df['adjust_factor'] df['low_adj'] = df['low'] * df['adjust_factor']

成交量不需要复权,但要注意除权日的成交量可能异常放大,做放量判断时要考虑这个因素。

6.3 未来函数的识别与规避

通达信公式里有些写法是隐式的未来函数,改写时如果不注意,回测会虚高。常见的未来函数模式:

  • REF(X, -N):直接引用未来数据
  • ZIG、PEAK、TROUGH:这些函数在通达信里是事后确认的,实盘不可用
  • BACKSET:把条件成立的位置向前回溯,也是未来函数

改写时遇到这些函数,要么直接放弃,要么用近似方法替代。比如ZIG可以用固定百分比的回撤来判断转折点,虽然不如ZIG精确,但至少实盘可用。

6.4 实盘与回测的一致性保障

回测跑通了不代表实盘能用。我总结了几条保障一致性的经验:

第一,数据源要统一。回测用什么数据,实盘就用什么数据,不要回测用前复权、实盘用不复权。

第二,计算时点要明确。通达信公式默认用当日收盘价计算,实盘如果盘中计算,要用最新价替代收盘价,但要注意盘中数据不完整的问题。

第三,信号确认要延迟。回测时信号在收盘后确认,实盘也要等收盘后再下单,不要盘中看到信号就冲进去。

第四,异常值要处理。涨跌停、停牌、新股上市首日这些特殊情况,回测和实盘的处理方式要一致。

7. 我个人的几条实操心得

改写通达信公式这件事,我做了不下几百个,有几个体会是文档里不会写的。

第一,不要追求100%一致。通达信和Python的计算引擎不同,浮点精度、数据源、复权方式都有细微差异,追求完全一致是徒劳的。只要信号方向一致、数量差异在可接受范围内(比如5%以内),就可以用。

第二,先跑通再优化。很多人一上来就想写向量化、用numba,结果逻辑还没验证就陷入性能优化的泥潭。我的做法是先用最笨的循环写一版,确认结果对了,再逐步优化。

第三,保留原始公式作为注释。改写后的Python代码里,把对应的通达信公式用注释保留在函数上方。这样以后回头看,能快速理解代码意图,也方便别人接手。

第四,建立自己的函数库。MA、EMA、CROSS、BARSLAST这些函数,改写一次就封装好,下次直接调用。我现在的函数库积累了上百个函数,新公式改写基本就是拼积木。

第五,测试用例要覆盖边界。数据不足、停牌、涨跌停、除权除息,这些边界情况一定要写测试用例。我见过太多策略在正常行情下跑得好好的,一遇到除权就崩了。

最后分享一个小技巧:如果你不确定某个通达信函数的精确语义,可以在通达信里写一个最简单的公式,比如X: BARSLAST(CLOSE>OPEN);,然后导出数据,用几根K线手动验证。这比查文档快得多,也更可靠。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进