第 2 章 · 01 MACD Oscillator:均线交叉的起点 本节摘要:MACD 是 quant-trading 仓库里最短、最基础、也最经典的策略——作者直接称之为"trading strategy 101"。全脚本仅 143 行,却把"动量交易"的核心骨架完整立了起来:在收盘价上算快慢两条简单移动平均(SMA),快线上穿慢线就满仓、下穿就空仓,两线之差画成震荡柱(oscillator)。本节带你逐行精读这套逻辑,并建立贯穿全书的 positions/signals 双列约定。读完你会明白:为什么一个连小学生都能理解的指标,能在市场里流传四十年。 涉及源码:原项目 (143 行),README 第 1 节。
本节摘要:MACD 是 quant-trading 仓库里最短、最基础、也最经典的策略——作者直接称之为"trading strategy 101"。全脚本仅 143 行,却把"动量交易"的核心骨架完整立了起来:在收盘价上算快慢两条简单移动平均(SMA),快线上穿慢线就满仓、下穿就空仓,两线之差画成震荡柱(oscillator)。本节带你逐行精读这套逻辑,并建立贯穿全书的 positions/signals 双列约定。读完你会明白:为什么一个连小学生都能理解的指标,能在市场里流传四十年。
涉及源码:原项目
MACD Oscillator backtest.py(143 行),README 第 1 节。
⚠️ 注意:本脚本头部
import fix_yahoo_finance as yf用的是已废弃库,运行前请按第 1 章 02 节改成import yfinance as yf。所有回测仍是无摩擦假设,不构成投资建议。
MACD 全称 Moving Average Convergence/Divergence(移动平均收敛/发散)。它是动量交易(momentum trading)的入门范式,核心信念只有一句话——
价格的上行/下行动量,对短期均线的影响,大于对长期均线的影响。
这句话翻译成数学,就是把短期均线 MA_1 减去长期均线 MA_2,得到一个差值 O:
当价格突然上涨,短期均线(只看最近几天)立刻被拽上去,长期均线(看几十天)反应迟钝,于是 O_t 变正;反之价格下跌,O_t 变负。这个差值 O 时正时负、来回穿越零线,所以叫"收敛/发散震荡量(oscillator)"。MACD 三个字母里,Convergence 指两线收敛(差值缩小),Divergence 指两线发散(差值放大)。
交易规则朴素到不能再朴素——
作者在 README 里点出一个关键观察:MACD 是"任何毫无金融背景的人五分钟就能上手"的策略,所以它成了非专业交易者里最普及的策略。然后他抛出一个行为金融学的论断——
越多人相信某个策略,这个策略就越有效(不一定总成立,比如 2008 年就失效过)。
这就是著名的"自证预言"(self-fulfilling prophecy):当成千上万的散户、甚至部分机构都在用 MACD 金叉死叉做决策时,金叉出现那一刻,海量的买盘会自动把价格推上去,反过来"验证"了这个信号。短期来看,MACD 的有效性很大程度上来自"用的人多"这个正反馈。理解这一点很重要——它意味着 MACD 在参与者众多、流动性好的市场(美股大盘、主流外汇)里更灵,在小众品种上可能失效。
经典 MACD 的默认参数是 快线 12、慢线 26。作者在代码注释里讲了一个冷知识——
# once a upon a time you got six trading days in a week # so it is two week moving average versus one month moving average # for now, the ideal choice would be 10 and 21
翻译过来:从前(上世纪六七十年代)美国股市一周交易六天,所以 12 ≈ 两周,26 ≈ 一个月,"两周均线 vs 一个月均线"是个很自然的故事。现在改成五天交易周后,合理的对应应该是 10(两周)和 21(一个月,约 21 个交易日)。但 12/26 已经成了行业惯例,所有软件默认值都是它,改了反而和别人对不上号,所以大家将错就错。
这个细节非常有意思——它提醒你:指标参数不是天降的神圣数字,而是历史路径依赖的产物。换一个市场(比如 24 小时交易的外汇)或换一个时代,最优参数都会变。
为什么"短期均线在上就做多"能赚钱?背后是行为金融学里的动量效应(Jegadeesh & Titman 1993 经典论文):投资者对新信息反应不足(underreaction),导致利好消息推动的价格趋势会持续一段时间,而不是一步到位。短期均线在上的那段时间,正是市场"还在缓慢消化利好"的窗口,顺趋势做多就能吃到这段惯性。当然,动量反过来就是反转——一旦趋势结束,均线交叉会让你"晚一拍"出场,这是 MACD 最大的痛点,稍后在反直觉发现里展开。
macd():算两条 SMAdef macd(signals): signals['ma1'] = signals['Close'].rolling(window=ma1, min_periods=1, center=False).mean() signals['ma2'] = signals['Close'].rolling(window=ma2, min_periods=1, center=False).mean() return signals
要点:
pandas 的 rolling().mean() 算简单移动平均(SMA),window 来自全局变量 ma1/ma2(在 main 里 input() 输入)。min_periods=1 是个细节:窗口没填满时(序列开头几天)也允许出值,用 1 个数据点就能算,避免前若干行是 NaN。代价是开头几条均线"失真",但因为回测会 slicer 切掉开头一段,影响不大。center=False 表示窗口右对齐(用当前及过去 N-1 天算),这是回测必须的——center=True 会偷看未来。注意:经典 MACD(以及后面 Awesome Oscillator 的对比版)用的是 EMA 指数加权,而本脚本为了简单用了 SMA。作者在 Awesome 脚本里特意补了一句"这次我给 MACD 用 ewma 指数平滑",说明他清楚两者差异,只是入门版故意用 SMA。
signal_generation():positions 与 signals 的双列魔法这是全书最重要的一个函数,务必逐行读懂——
def signal_generation(df, method): signals = method(df) # 调 macd(),填好 ma1/ma2 signals['positions'] = 0 # 先全部置 0 # 从第 ma1 行起,快线>=慢线就 1,否则 0 signals['positions'][ma1:] = np.where(signals['ma1'][ma1:] >= signals['ma2'][ma1:], 1, 0) # positions 只表示"持仓状态",取差分才是"实际下单" signals['signals'] = signals['positions'].diff() # 震荡量 = 快慢均线之差 signals['oscillator'] = signals['ma1'] - signals['ma2'] return signals
逐行拆解:
第 1 步——method(df) 回调:method 就是上面那个 macd 函数。这种把"算指标"作为参数传入的写法,后面 SAR、AO 全沿用,方便复用同一套信号生成框架。
第 2 步——np.where 生成持仓状态:np.where(条件, 1, 0) 是 numpy 的向量化 if-else。signals['positions'][ma1:] 这个切片很关键——前 ma1 行强制为 0,因为快线窗口还没填满,信号不可信。从第 ma1 行起,快线在上就 1(满仓),否则 0(空仓)。
第 3 步——positions.diff() 得下单信号:这是全书的"黄金一行"。positions 是"我现在拿着还是没拿",diff() 算相邻两行之差——
| 时刻 | positions | signals=diff | 含义 |
|---|---|---|---|
| t-1 | 0 | — | 空仓 |
| t | 1 | +1 | 这一根开多 |
| t+1 | 1 | 0 | 继续持有,不下单 |
| t+2 | 0 | -1 | 这一根平仓 |
signals 列里只有 +1、0、-1 三种值,+1/-1 才是真正的"下单时点"。画图时用 signals==1 标绿色上箭头(开多),signals==-1 标红色下箭头(平仓),一目了然。
第 4 步——oscillator 列:ma1 - ma2 的差值,即震荡柱。它为正时满仓,为负时空仓,正是持仓状态的数学投影。
plot():两幅图的故事plot 函数画两幅图,对应两张配图——


第一幅(持仓图):收盘价折线 + signals==1 处画绿色 ^、signals==-1 处画红色 v。箭头就是金叉/死叉点。
第二幅(震荡柱图):把 oscillator 画成红色柱状图,正值在上、负值在下,直观展示动量强弱;下方再叠 ma1/ma2 两条均线,虚线是慢线。
注意震荡柱的颜色——本脚本里作者统一用红色(color='r'),没有按正负染色;到 Awesome Oscillator 那一节,他会改成"根据当天 K 线红绿(开收关系)染色",那才是更标准的 AO 画法。
main():交互式入口def main(): global ma1, ma2, stdate, eddate, ticker, slicer ma1 = int(input('ma1:')) ma2 = int(input('ma2:')) stdate = input('start date in format yyyy-mm-dd:') eddate = input('end date in format yyyy-mm-dd:') ticker = input('ticker:') slicer = int(input('slicing:')) df = yf.download(ticker, start=stdate, end=eddate) new = signal_generation(df, macd) new = new[slicer:] plot(new, ticker)
几个值得注意的工程选择:
global 声明:ma1/ma2 在 macd() 函数里被引用,但定义在 main 里,所以用 global 跨函数共享。这是作者的偷懒写法(更规范的是把参数传进 macd(signals, ma1, ma2)),但对单文件脚本够用。slicer 切片:两年数据点太多,画图会"箭头挤成一团"。slicer 让你只画后 N 个交易日,这是可视化必需的"窗口"。yf.download:运行时现拉数据,所以脚本不需要本地 CSV(但需要联网,且要把 fix_yahoo_finance 换成 yfinance)。作者在 main 上方留了一段非常真诚的注释,值得完整引用——
# macd is easy and effective # there is just one issue # entry signal is always late # watch out for downward EMA spirals!
🎯 反直觉发现:MACD 最大的痛点,作者自己一句话点破——入场信号永远滞后。因为均线本质是"过去 N 天的平均",任何趋势反转都要等价格走出来若干天后,均线才会反应。这意味着:① 你永远买在"趋势已经走了一段"的位置,而非最低点;② 在剧烈反转(尤其 EMA 下行螺旋,即指数均线被急跌越拽越低)里,你刚追进去就可能接到山顶。所以"金叉死叉"看似精准,实则是**滞后指标(lagging indicator)**的宿命。后面 Awesome Oscillator 正是为了"抢在均线交叉之前"而设计,但抢前有没有用?那是下一节的高潮。
rolling(window=N, min_periods=1, center=False):回测里算 SMA 的标准三连参数,center=False 防偷看未来,min_periods=1 防开头 NaN。np.where(条件, 1, 0) 向量化生成持仓:比写 for 循环快几百倍,且代码极简。positions.diff() 得下单信号:全书统一的"状态→动作"转换法,记住这行,后面 SAR、AO、Pair Trading 全沿用。signals[ma1:] 切掉开头:窗口未填满的前若干行,持仓强制为 0,避免用失真均线下单。global 跨函数共享参数:作者的偷懒写法,读他的代码时遇到 global 不要慌,只是省了传参。oscillator = ma1 - ma2 即震荡量。下一节,我们看作者如何用 (H+L)/2 + saucer 碟形信号"升级"MACD,以及一个让人意外的实测结论:升级版真的更好吗?