上一篇我们学会了收益率和波动率,把「每天涨跌」抽象成了可比的数字。但你很快会发现一个问题:收益率是一天一变的「毛刺信号」,天天都在上蹿下跳,盯着它做判断,人会被晃晕。实战里我们需要一种能滤掉日内噪声、看清方向的东西——这就是均线。打开任何一个行情软件,叠加的第一条指标几乎都是它,堪称量化世界的「第一条指标」。
本篇我们把两条最重要的均线讲透:SMA(简单移动平均)和EMA(指数移动平均)。学完你不仅会用代码算它们,还会理解它们「为什么长这样」、它们俩的侧重差异,并亲手标记出传说中的金叉与死叉。更重要的是,本篇会为第 18 篇的「双均线回测」埋好伏笔——那里我们会第一次跑真正的回测,而回测的原材料,就是今天这两条线。
本篇目标
学完本篇,你能够做到:
- 用一句话说清 SMA 和 EMA 的原理差异:SMA 等权平均,EMA 越近的数据权重越高。
- 用
rolling算 SMA、用ewm算 EMA,并解释两种实现的关键参数。 - 把价格、SMA、EMA 画在一张图上,直观对比两者「谁跟得更紧、谁更平滑」。
- 用两条均线(如 MA5 与 MA20)生成金叉/死叉信号,并用散点标注在图上。
- 复述均线的滞后性从何而来、对信号意味着什么,知道它如何影响第 18 篇的回测预期。
核心概念
SMA:人人都会的算术平均
SMA(Simple Moving Average,简单移动平均)的公式:
MA_t = (P_t + P_{t-1} + ... + P_{t-n+1}) / n
白话版:把最近 n 天的收盘价加起来除以 n,就是今天的均线值;每天往后挪一天重算一次。它的性格特点全部来自「等权」:昨天和 20 天前的数据在 MA20 里话语权一样大,所以旧数据退出窗口的那天,均线值会突然跳一下——哪怕当天行情风平浪静。pandas 里 rolling(n).mean() 一行搞定,第 5 篇学的滚动计算在这里正式上岗。
EMA:越近的事越重要
EMA(Exponential Moving Average,指数移动平均)给不同时间的数据分配递减的权重:昨天的价格权重最大,越久远的权重越小,按指数衰减。递推公式:
EMA_t = α × P_t + (1 − α) × EMA_{t-1},其中 α = 2 / (n + 1)
白话版:今天的 EMA 等于「今天的价乘一个系数」加上「昨天的 EMA 乘剩余系数」——它不是一个窗口平均,而是一个「记忆逐渐淡忘」的过程:今天的信息立刻进入计算,远古的信息权重趋近于零但永远不真正归零。α = 2/(n+1) 是与「n 日均线」对应的惯用换算:n 越大 α 越小,记忆越长。pandas 里 ewm(span=n, adjust=False).mean() 一行实现,span=n 对应上面这个 α,adjust=False 指定用递推方式计算(与行情软件的算法一致)。
SMA vs EMA:性格差异一览
| 维度 | SMA | EMA |
|---|---|---|
| 权重 | 窗口内等权 | 越近权重越高,指数衰减 |
| 反应速度 | 较慢,钝一些 | 较快,更贴近价格 |
| 平滑程度 | 更平滑,噪声少一些 | 灵敏但抖动更多 |
| 边界表现 | 窗口满才开始,旧数据退出时可能跳变 | 从第一个数据点就开始,逐渐收敛 |
| 典型用途 | 看长期趋势的「基准线」 | 需要快速反应的短线信号 |
白话总结:SMA 像慢性子的老会计,EMA 像反应快的雷达。没有谁对谁错——SMA 抗噪但要慢半拍,EMA 敏锐但更容易被假突破晃点。后面 MACD(第 11 篇)用的就是 EMA,而本篇的对比图会让你亲眼看到两者的差异。
金叉与死叉:两条线的「十字路口」
把一条短周期均线(如 MA5,代表近期成本)和一条长周期均线(如 MA20,代表稍远的平均成本)画在一起:短线上穿长线叫金叉(Golden Cross),常被解读为短期力量走强的标志;短线下穿长线叫死叉(Death Cross),反之。注意措辞:我们只说「标志」,不说「必然上涨/下跌」——均线系统的历史表现如何,要靠第 18 篇的回测说话,现在先学会把信号找出来。
划重点:信号判断只用「昨天的均线位置」推导「今天的叉」,绝不用未来数据。判断第 t 天是否金叉,只能用
MA5[t] > MA20[t]且MA5[t-1] <= MA20[t-1]——这两个条件全部基于当天及之前的均线值,没有偷看明天。
滞后性:均线的宿命
均线是对历史价格的平均,无论权重怎么设计,它的转折一定晚于价格的转折——价格先见底,均线后拐头,这是平均的天性,不是 bug。SMA 与 EMA 的差异本质上就是「滞后程度」的差异:EMA 权重偏近所以滞后更少,SMA 更平滑所以滞后更多。滞后的代价是:趋势反转时你晚一步进场/出场;收益是:震荡市里它帮你滤掉大量假信号。这对「快与稳」的权衡会伴随你整个量化学习路——第 18 篇回测时你会亲眼看到,双均线策略在趋势段吃肉、在震荡段挨打,根源就在这里。
完整代码
本篇依赖 pandas、numpy 和 matplotlib:
uv pip install pandas numpy matplotlib
数据继续用第 8 篇落盘的本地文件,fallback 逻辑与第 9 篇一致。第一段:取数并计算 MA5、MA20、EMA5、EMA20。
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
plt.rcParams["font.sans-serif"] = ["SimHei"] # 中文显示,第 6 篇讲过
plt.rcParams["axes.unicode_minus"] = False
def load_data() -> pd.DataFrame:
"""三级 fallback:Parquet → CSV → 本地造数据(与第 9 篇一致)。"""
try:
df = pd.read_parquet("000001_clean.parquet")
print("已读取 000001_clean.parquet")
except Exception:
try:
df = pd.read_csv("000001_clean.csv", index_col="date", parse_dates=["date"])
print("已读取 000001_clean.csv")
except Exception:
print("未找到第 8 篇的数据文件,使用本地模拟数据")
rng = np.random.default_rng(1)
n = 1215
idx = pd.bdate_range("2020-01-02", periods=n)
close = 16 * np.exp(np.cumsum(rng.normal(0.0002, 0.02, n)))
df = pd.DataFrame({"close": close}, index=idx)
df.index.name = "date"
return df[["close"]].dropna()
df = load_data()
df["ma5"] = df["close"].rolling(5).mean() # SMA:5 日简单均线
df["ma20"] = df["close"].rolling(20).mean() # SMA:20 日简单均线
df["ema5"] = df["close"].ewm(span=5, adjust=False).mean() # EMA:5 日指数均线
df["ema20"] = df["close"].ewm(span=20, adjust=False).mean() # EMA:20 日指数均线
print(df.tail())
print(f"MA5 与 EMA5 的平均绝对差:{(df['ma5'] - df['ema5']).abs().mean():.4f}")
第二段:把价格、MA20、EMA20 画在一张图上对比;然后生成金叉死叉信号并用散点标注。
# ---- 图一:SMA 与 EMA 的形态对比 ----
fig, ax = plt.subplots(figsize=(12, 6))
ax.plot(df.index, df["close"], color="#bbbbbb", linewidth=0.9, label="收盘价")
ax.plot(df.index, df["ma20"], color="#1f77b4", linewidth=1.4, label="MA20(简单均线)")
ax.plot(df.index, df["ema20"], color="#ff7f0e", linewidth=1.4, linestyle="--", label="EMA20(指数均线)")
ax.set_title("SMA 与 EMA 对比:EMA 更贴近价格,SMA 更平滑")
ax.set_ylabel("价格(元)")
ax.legend()
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
# ---- 金叉死叉信号:只用当日及之前的均线值判断 ----
df["cross_up"] = (df["ma5"] > df["ma20"]) & (df["ma5"].shift(1) <= df["ma20"].shift(1)) # 金叉
df["cross_down"] = (df["ma5"] < df["ma20"]) & (df["ma5"].shift(1) >= df["ma20"].shift(1)) # 死叉
print(f"金叉次数:{df['cross_up'].sum()},死叉次数:{df['cross_down'].sum()}")
# ---- 图二:把信号标在价格与 MA5/MA20 上 ----
fig, ax = plt.subplots(figsize=(12, 6))
ax.plot(df.index, df["close"], color="#bbbbbb", linewidth=0.9, label="收盘价")
ax.plot(df.index, df["ma5"], color="#d62728", linewidth=1.2, label="MA5")
ax.plot(df.index, df["ma20"], color="#1f77b4", linewidth=1.2, label="MA20")
ax.scatter(df.index[df["cross_up"]], df["ma5"][df["cross_up"]],
marker="^", color="#d62728", s=110, zorder=5, label="金叉")
ax.scatter(df.index[df["cross_down"]], df["ma5"][df["cross_down"]],
marker="v", color="#2ca02c", s=110, zorder=5, label="死叉")
ax.set_title("MA5 / MA20 金叉死叉标注(信号仅作教学演示)")
ax.set_ylabel("价格(元)")
ax.legend()
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
进阶一步:把信号装进「信号事件表」。 实际做研究时,散点图之外的信号更需要一张干净的表格:哪天、什么信号、当天的收盘价是多少——第 18 篇的回测代码会直接消费这张表。下面几行把布尔信号列转成事件表,顺便演示 pandas 的多条件筛选:
# ---- 把金叉死叉整理成信号事件表 ----
signals = df.loc[df["cross_up"] | df["cross_down"], ["close", "ma5", "ma20"]].copy()
signals["signal"] = np.where(df.loc[signals.index, "cross_up"], "金叉", "死叉")
signals["prev_signal"] = signals["signal"].shift(1).fillna("首条") # 自检:信号应大致红绿交替
print(f"信号总数:{len(signals)}")
print(signals.head())
# 顺手做个粗检:金叉之后是否大多跟着更高的 5 日均线(仅为教学观察)
golden = signals[signals["signal"] == "金叉"]
next_ma5 = df["ma5"].shift(-5) # 未来 5 日的 MA5,仅事后统计用
print(f"金叉后 5 日 MA5 高于信号日的比例:"
f"{(next_ma5[golden.index] > golden['ma5']).mean():.0%}")
# 存成 CSV(第 8 篇的姿势),第 18 篇回测将直接读取这张表
signals.to_csv("ma_signals.csv", encoding="utf-8-sig")
两处细节:df.loc[布尔或, 列清单] 用「按行筛 + 按列选」一步取出信号日的关键数据;np.where 把布尔列翻译成人话标签。最后的比例统计用了 shift(-5),这是故意用了未来数据的事后统计——它只能用来观察信号的历史质量,绝不能写进策略决策代码,这个关键区别第 17 篇会正式展开。示例输出示意:
信号总数:42
close ma5 ma20 signal prev_signal
date
2020-02-14 15.3 15.1 14.9 金叉 首条
2020-03-09 14.6 14.9 15.0 死叉 金叉
...
金叉后 5 日 MA5 高于信号日的比例: 52%
代码逐段解释
第 1 段:四条均线各一行。 SMA 用 rolling(n).mean():窗口滑过整个序列,每个位置取窗口内均值,窗口不满的前 n-1 天是 NaN——这是「数据不足,宁缺毋滥」的诚实表现,不要去填。EMA 用 ewm(span=n, adjust=False).mean():span=5 对应 α=2/(5+1)=0.333,adjust=False 让 pandas 用递推式计算,与通行情软件的口径一致。最后打印 MA5 与 EMA5 的平均绝对差,你会发现两条线方向高度一致但数值天天有出入——差异就来自权重的分配方式。示例输出示意(数值为示意,与本地模拟数据逻辑自洽):
close ma5 ma20 ema5 ema20
date
2024-12-25 15.90 15.82 15.71 15.86 15.68
2024-12-26 16.02 15.94 15.76 15.97 15.72
...
MA5 与 EMA5 的平均绝对差:0.0312
第 2 段:对比图与信号标注。 图一把灰色价格线、蓝色 MA20、橙色虚线 EMA20 叠在一起:你能看到橙色线在拐弯处总是先动、更贴价,蓝线更平缓——这就是「权重偏近 vs 等权」的视觉效果。金叉条件写成两行的布尔逻辑:今天 MA5 在 MA20 上方 且 昨天 MA5 不在 MA20 上方(<=),两个条件同时成立才算「刚上穿」;shift(1) 取昨天的均线值,全部用的是当天及以前的数据,没有未来函数。死叉对称。scatter 的取法 df.index[布尔序列] 是「挑出信号为真的那些日期」,Y 坐标用对应日的 MA5 值,marker="^"/"v" 画上下三角,zorder=5 让标记浮在均线之上不被遮住。示例输出示意:
金叉次数:21,死叉次数:21
划重点:
shift(1)是均线信号的生命线。少了它,条件「今天 MA5 > MA20」会把整个上行区间每天都当成金叉,信号数量爆炸;有了它,只在「上穿动作发生」的那一天为 True,这才是「叉」的含义。
关于信号数量。 你数出来的金叉死叉次数和示意数字不一样很正常——它取决于你用的是真实数据还是本地模拟数据、以及均线周期。趋势年份叉少而有效,震荡年份叉多而破碎。先别急着评价这套信号好不好:第 18 篇我们会把今天标记的这些叉变成买卖动作,扣除双边 0.1% 手续费后算一算真实盈亏,让数据自己开口。
常见坑与小练习
坑 1:金叉信号连着好几天都为 True。 现象:一个金叉被当成五天信号。原因:条件漏了 shift(1),把「位于上方」误当「上穿动作」。避免方式:信号必须写成「今天为真 且 昨天为假(或 <=)」的跨越式判断。
坑 2:把 ewm 的 adjust 参数当没写。 现象:你算的 EMA 和行情软件对不上。原因:adjust=True(默认)用加权平均式,起点权重分配不同。避免方式:与软件口径对齐时写 adjust=False;做研究时则应固定一种口径并保持全文一致。
坑 3:均线头部比数据头部「少一截」。 现象:图上 MA20 前 19 天空白。原因:窗口不满不计算,这是正常行为。避免方式:别用 fillna 硬填,画图时让 matplotlib 跳过 NaN 即可;如果一定要从头展示,可以准备更早的数据再裁剪展示区间。
坑 4:信号点被均线盖住看不见。 现象:scatter 画了但图上找不到三角。原因:绘制层级低或标记太小。避免方式:zorder=5 提高层级,s=110 放大标记;也可以用 annotate 加文字标注。
坑 5:用「金叉必涨」的思维看信号。 现象:看到死叉就恐慌、看到金叉就梭哈。原因:把技术信号的统计倾向当成了确定性结论。避免方式:信号只是统计意义上的倾向,有效性必须回测验证——这正是本系列下一步要做的事,也是本篇把话说到七分满的原因。
小练习 1: 把均线周期改成 10 和 30 再跑一遍,观察信号次数变化,并回答:周期放大后金叉死叉是变多了还是变少了?为什么?
思路提示:均线越长越平滑,穿越次数通常越少、但每次穿越的「分量」越重;对比两组信号在图上的位置差异。
小练习 2: 用 min_periods 参数让 rolling(20).mean(min_periods=5) 在数据不足 20 天时也能出值,对比前 20 天的均线形状变化,并想一想:这种「提前出值」是用什么代价换来的?
思路提示:代价是均线前段基于更少样本、更不稳定;体会「诚实等待」与「尽早可用」的权衡。
小练习 3: 把信号列与第 9 篇的日收益率对齐,粗略统计「金叉之后 5 天的平均收益」和「死叉之后 5 天的平均收益」(提示:df["simple_ret"].shift(-5) 表示未来 5 天收益,这里仅为教学统计使用)。
思路提示:
df.loc[df["cross_up"], "fwd_ret"].mean();注意shift(-5)用了未来数据,只能做事后统计,绝不能进策略决策代码——这个区别第 17 篇会大讲特讲。
本文策略仅用于教学,不构成投资建议,实盘前请充分测试。