趋势指标:SMA / EMA / MACD
手写、对拍、再审判一次金叉
两个人都说"我用 EMA(12)",他们算出来的可能不是同一条线。
实测:同一份数据、同一个周期参数,我的手写实现与 pandas 默认的 ewm(span=12),最大绝对差 8.72 点(相对 0.259%)。换一个参数(adjust=False),差距缩到 0.88 点。
三条线都"对",只是初始化方式不同。而如果你拿 A 平台的信号去验证 B 平台的策略,你会把实现差异误当成策略差异——这类 bug 极难查,因为代码两边都没错。
这一节做三件事:手写三个趋势指标、与库对拍、然后对最流行的 MACD 金叉做一次横截面统计审判。
两个厨师都说"加一勺盐"。一个用的是茶匙,一个用的是汤匙。
菜谱没写错,两个人也都照做了。但做出来的是两道菜。
指标也一样:名字相同、参数相同,实现细节不同,结果就是两条线。
一、手写:先写清楚定义
def sma(s, n):
"""前 n 项无定义 —— 不要用 0 或前值填充,那是在造数据。"""
return s.rolling(n, min_periods=n).mean()
def ema_recursive(s, n):
"""EMA_t = a*x_t + (1-a)*EMA_{t-1},a = 2/(n+1);初值取前 n 项算术平均。"""
a = 2 / (n + 1)
out = np.full(len(s), np.nan)
x = s.to_numpy(dtype=float)
out[n - 1] = x[:n].mean()
for i in range(n, len(x)):
out[i] = a * x[i] + (1 - a) * out[i - 1]
return pd.Series(out, index=s.index)
min_periods=n 那一行不是洁癖:如果用前 3 天的均值冒充 20 日均线,你就是在给自己造数据,而且这个错误会一直污染序列的开头,恰好是回测开始的地方。
二、对拍:SMA 谁写都一样,EMA 不是
| 对比 | 最大绝对差 | 最大相对差 | 末值相对差 |
|---|---|---|---|
手写 SMA(20) vs rolling(20).mean() | 0.00e+00 | — | — |
手写 EMA(12) vs ewm(span=12, adjust=True)(pandas 默认) | 8.7170 | 0.2590% | 0.000000% |
手写 EMA(12) vs ewm(span=12, adjust=False) | 0.8827 | 0.0252% | 0.000000% |
三件事:
- SMA 没有歧义,谁写都一样
- EMA 有歧义:
adjust=True(pandas 默认)在序列开头用的是加权平均的精确展开,adjust=False用纯递推,而交易软件常用"前 n 项均值作初值"——三种都合理,前期能差 0.26% - 末值完全一致——因为 EMA 的初值影响会随时间指数衰减
"末值一致"正是这个坑最阴的地方。
你随手打印最后几行对比一下,发现完全一样,于是放心了。但回测是从序列开头跑起的——开头那段差 0.26%,足以让金叉死叉的时点错开好几天,而且错在样本的最前面,影响会顺着净值曲线一路累积。
验证指标实现,要对拍整条序列的最大差,不是末值。
🔧 动手做:SMA 谁写都一样,EMA 不是(5 分钟)
跑 python trend_indicators.py,把手写指标和 pandas 现成的对拍。
你会看到(实测):手写 SMA(20) 和 rolling(20).mean() 最大绝对差 0.00——谁写都一样。但 EMA 不是:同一条数据,ewm(adjust=True) 和 adjust=False 给出不同的值,而很多库默认口径还不一样。
想明白:指标是定义,不是真理。SMA 只有一种算法,EMA 却有好几种(初值怎么设、adjust 开不开)。你直接 import 一个 EMA,可能和别人算的、和你回测里假设的根本不是一条线。用库没问题,但你得知道你用的是哪种口径——所以先手写一遍、对拍一次,你才知道现成的那个到底在算什么。
三、那为什么不直接用现成的库
这是个好问题,而且必须正面回答:pandas-ta、TA-Lib 提供 150+ 个现成指标,为什么这门课要手写?
答案分两半。
教学上必须手写
因为只有手写才暴露得出实现分歧。上面那 8.72 点的差异、下一节 RSI 两种平滑导致"上穿 70"次数相差 2.7 倍——直接调库,这些坑会被完全掩盖,而它们恰恰是你复现别人策略时最费时间的部分。
生产上应该用库,但上线前必须对拍
库经过更多人验证、边界处理更全。我们把手写实现与 pandas-ta 0.4.71b0 逐个对拍:
| 手写 vs pandas-ta | 最大绝对差 | 相关系数 | 结论 |
|---|---|---|---|
| SMA(20) | 0.000000 | 1.000000 | 完全一致 |
| EMA(12) | 0.000000 | 1.000000 | 完全一致 |
| RSI(14) | 0.000000 | 1.000000 | 完全一致 |
| MACD DIF | 0.000000 | 1.000000 | 完全一致 |
| ATR(14) | 5.568572 | 0.999966 | 高度一致,平滑细节不同 |
| BOLL 位置 | 0.049060 | 1.000000 | 高度一致,ddof 取值不同 |
这张表里藏着一个比"一致"更重要的信息。
手写 EMA 与 pandas-ta 完全一致(差 0.000000),却与 pandas 原生 ewm(adjust=True) 差 8.72 点。
原因:pandas-ta 用的是交易软件的惯例(纯递推 + 前 n 项均值作初值),而 pandas 原生 ewm 默认 adjust=True,用的是加权平均的精确展开。
两个都叫 EMA,两个都是对的,而它们的默认行为不同。
所以规则不是"用库就安全",而是:换任何一个库/平台,都要先对拍一遍,并把差异量级记进文档。这和 P4 章「自研引擎必须与 backtrader 对拍」是同一条方法论。
本课的分工:课程内手写,让你看清定义与分歧;真做项目时用 pandas-ta,但把 _shared/lib_crosscheck.py 挂进 CI,每次升级库都跑一遍。
四、向量化不是风格问题,是能力问题
纯 Python 循环 7.0ms | pandas 向量化 0.2ms | 快 45 倍(4023 个点)
单条序列差 45 倍,看起来无所谓。但当你要做的是:50 只标的 × 20 组参数 × 6 个持有期的检验矩阵——这个倍数决定你一天能试几次,也就决定你的研究迭代速度。
五、审判:MACD 金叉到底有没有用
不看图说话,做横截面统计检验:50 只标的、7518 次金叉信号,把"金叉后 N 日收益"与"全样本随机时点的同期收益"比。
| 持有期 | 金叉后均值 | 基准均值 | 差值 | t 值 | p 值 |
|---|---|---|---|---|---|
| 1 日 | +0.139% | +0.055% | +0.084pp | 3.00 | 0.0027 |
| 3 日 | +0.292% | +0.167% | +0.125pp | 2.51 | 0.0120 |
| 5 日 | +0.371% | +0.280% | +0.091pp | 1.41 | 0.1593 |
| 10 日 | +0.673% | +0.558% | +0.115pp | 1.24 | 0.2146 |
| 20 日 | +1.300% | +1.128% | +0.172pp | 1.26 | 0.2094 |
| 60 日 | +3.740% | +3.432% | +0.307pp | 1.23 | 0.2183 |
胜率:金叉后为正 50.6%,全样本 49.7%。
诚实的读法:
- 方向是对的——六个持有期的差值全部为正,不是随机乱跳
- 短期(1–3 日)在 5% 水平上显著,幅度约 0.08–0.13 个百分点
- 5 日以后不显著,信息很快被消化掉
- 0.084pp 是什么概念:回想 p0.2 的死亡线——单边成本 18.8bp 就能让一个策略跑输躺平。1 日 +0.084pp = 8.4bp,连一次双边交易成本都盖不住
所以结论不是"金叉没用",而是:它携带的信息量,小于交易它所需的成本。
这比"金叉是玄学"更准确,也更有用:它告诉你该往哪个方向改进——要么降低成本,要么找信息量更大的信号,而不是继续调 MACD 的参数。
这张表报了 6 个持有期,等于做了 6 次检验。
按 5% 显著性水平,即使金叉完全无效,平均也会有 0.3 次"显著"。我这里出现了 2 次。
那么这 2 次是真的吗?单看 p 值回答不了这个问题——需要对多重检验做校正。这笔账 P3.4 专门算,而且会把 P2 整章累计的检验次数一起清算。
从现在开始,养成一个习惯:每报一个 p 值,同时报你一共试了多少次。
📌 免责:本课为技术教学,统计结果为历史模拟,不构成投资建议;标的为沪深300 成分股按代码序取样,非精选。
三件事:
- SMA 没歧义,EMA 有:与 pandas 默认实现最大差 8.72 点(0.259%),而末值完全一致——所以要对拍整条序列
- 向量化快 45 倍:决定你一天能试几次
- MACD 金叉:方向对、短期显著、但幅度太小——1 日 +0.084pp = 8.4bp,连一次双边成本都盖不住
最后那条结论的形式,是这一章反复要用的:不说"有用/没用",说"信息量有多大、够不够付成本"。
下一节审判三句更流行的口诀:RSI 超过 70 该卖、低于 30 该买、KDJ 的 J 值破 0 是极度超卖。其中一条的实测结果,和口诀说的方向完全相反。
🔎 来源与核验· 4 条,点开核对
