ボリンジャーバンドのコードを書いていたとき、何の疑問もなくrolling(20).mean()と打ってから手が止まりました。「これはSMAだけど、EMAを使ってる人も多いよな。何が違うんだっけ」と。
調べると、どの解説にも「EMAは直近を重視するのでSMAより反応が速い」と書いてあります。それは間違いではないのですが、数式まで降りて調べたらもっと面白いことが分かりました。SMA(20)とEMA(span=20)は、ラグも、ノイズの減り方も、まったく同じなんです。
「じゃあ何が違うのか」「速い移動平均が欲しいならどうするのか」を、数式と実測の両方で整理しました。
移動平均の「ラグ」とは重みの重心のこと
どの移動平均も、過去の価格に重みを付けて足しているだけです。k日前の価格に対する重みをw(k)(合計1)とすると、その重み分布の平均が実質的な遅れになります。
ラグ = 0×w(0) + 1×w(1) + 2×w(2) + …(重みの重心)
これを3つの移動平均について計算すると、こうなります。
| 種類 | 重み | ラグ(日) | n=20のとき |
|---|---|---|---|
| SMA(n) | すべて 1/n | (n−1)/2 | 9.5日 |
| EMA(span=n) | α(1−α)k、α=2/(n+1) | (n−1)/2 | 9.5日 |
| WMA(n) | n, n−1, …, 1 に比例 | (n−1)/3 | 6.3日 |
SMAとEMAのラグが完全に一致します。偶然ではありません。EMAの平滑化係数がα = 2/(n+1)と定義されているのは、まさに「SMA(n)と同じ重心になるように」逆算した結果だからです。
EMAのラグは(1−α)/αで計算できます。ここにα = 2/(n+1)を代入すると、きれいに(n−1)/2になります。「EMA20はSMA20より速い」という説明は、この意味では正しくありません。
ノイズの減り方も同じ
もう一つ驚いたのがこちらです。入力にホワイトノイズが乗っているとき、出力の分散は元の分散 ×(重みの二乗和)になります。この二乗和を「ノイズゲイン」と呼びます。小さいほど滑らかです。
| 種類 | ノイズゲイン | n=20 |
|---|---|---|
| SMA(n) | 1/n | 0.0500 |
| EMA(span=n) | α/(2−α) = 1/n | 0.0500 |
| WMA(n) | ≈ 4/(3n) | 0.0683 |
これも一致します。SMA(20)とEMA(20)は、遅れも滑らかさも同じ。つまり「SMAとEMA、どちらが優れているか」という議論には、少なくとも1次の性質では答えがありません。
WMAは面白い位置にいます。ラグが2/3に減る代わりに、ノイズゲインが1.37倍になります。速さとノイズは交換関係にあるということが、ここに数字で出ています。
Pythonで確かめる
import numpy as np
import pandas as pd
def weights_sma(n: int) -> np.ndarray:
return np.full(n, 1 / n)
def weights_ema(n: int, trunc: int = 500) -> np.ndarray:
a = 2 / (n + 1)
w = a * (1 - a) ** np.arange(trunc)
return w / w.sum() # 打ち切り誤差を正規化で吸収
def weights_wma(n: int) -> np.ndarray:
w = np.arange(n, 0, -1, dtype=float)
return w / w.sum()
def describe(name: str, w: np.ndarray) -> dict:
k = np.arange(len(w))
return {
"移動平均": name,
"ラグ(日)": round(float((k * w).sum()), 2),
"ノイズゲイン": round(float((w ** 2).sum()), 4),
"実効サンプル数": round(1 / float((w ** 2).sum()), 1),
}
rows = [describe("SMA(20)", weights_sma(20)),
describe("EMA(20)", weights_ema(20)),
describe("WMA(20)", weights_wma(20)),
describe("SMA(13)", weights_sma(13)),
describe("WMA(30)", weights_wma(30))]
print(pd.DataFrame(rows).to_string(index=False))
「実効サンプル数」(ノイズゲインの逆数)を出しているのは、公平な比較のためです。SMA(20)とEMA(20)はどちらも実効20サンプル、WMA(30)はラグ9.7日で実効22サンプル。こうして並べると、期間の数字ではなくラグとノイズで比べるべきだと分かります。
ステップ応答で実測する
理屈は分かったので、実際に「価格が一段上がったとき、何日で追いつくか」を測ります。
def step_response(n: int = 20, jump_at: int = 100, length: int = 200):
"""jump_at日目に価格が100→110に飛ぶ階段入力への応答。"""
p = pd.Series(np.r_[np.full(jump_at, 100.0),
np.full(length - jump_at, 110.0)])
out = pd.DataFrame({
"price": p,
"SMA": p.rolling(n).mean(),
"EMA": p.ewm(span=n, adjust=False).mean(),
"WMA": p.rolling(n).apply(
lambda x: np.dot(x, np.arange(1, n + 1)) / (n * (n + 1) / 2),
raw=True),
})
print(f"{'指標':<5}{'50%到達':>8}{'90%到達':>8}")
for col in ("SMA", "EMA", "WMA"):
s = out[col]
d50 = int((s >= 105).idxmax()) - jump_at
d90 = int((s >= 109).idxmax()) - jump_at
print(f"{col:<5}{d50:>8}{d90:>8}")
return out
step_response()
結果が興味深いところです。50%に到達する日数はSMAとEMAでほぼ同じ(10日前後)。ところが90%到達では、SMAが19日なのに対してEMAは45日以上かかります。
これが両者の本当の違いです。SMAは20日経てば古いデータが窓から完全に消えるので、きっちり追いつきます。EMAの重みは指数的に減るだけでゼロにはならないため、いつまでも古い価格を引きずります。
| SMA | EMA | |
|---|---|---|
| 重心(ラグ) | (n−1)/2 | 同じ |
| ノイズ低減 | 1/n | 同じ |
| 初動の速さ | やや遅い | 速い |
| 収束の速さ | n日で完了 | いつまでも尾を引く |
| 古い暴落の影響 | n日で完全に消える | 薄く残り続ける |
| 計算量 | 窓の再計算 | 1行の漸化式 |
EMAは初動が速く、SMAは決着が速い。「EMAのほうが反応が速い」という通説は、初動だけを見た表現だったわけです。
本気でラグを削るならHMA
速さが欲しいなら、EMAに変えても意味がありません。ラグを能動的に打ち消す方法を使います。Hull移動平均(HMA)は、短期と長期のWMAの差分でラグを相殺します。
def wma(s: pd.Series, n: int) -> pd.Series:
w = np.arange(1, n + 1, dtype=float)
return s.rolling(n).apply(lambda x: np.dot(x, w) / w.sum(), raw=True)
def hma(s: pd.Series, n: int) -> pd.Series:
"""2*WMA(n/2) - WMA(n) を WMA(sqrt(n)) で平滑化。"""
half, root = int(n / 2), int(np.sqrt(n))
return wma(2 * wma(s, half) - wma(s, n), root)
def zlema(s: pd.Series, n: int) -> pd.Series:
"""ラグぶん先回りした価格をEMAに通す(ゼロラグEMA)。"""
lag = (n - 1) // 2
return (s + (s - s.shift(lag))).ewm(span=n, adjust=False).mean()
どちらも「差分を足して先回りさせる」発想です。実際にステップ応答を測ると、HMAは数日で追いつきます。
ただし代償があります。先回りするぶんオーバーシュートし、階段の直後に目標値を一時的に超えます。そしてノイズゲインが跳ね上がります。
def noise_gain_empirical(func, n: int = 20, trials: int = 4000,
seed: int = 0) -> float:
"""ホワイトノイズを入れて出力の分散を測る。"""
rng = np.random.default_rng(seed)
x = pd.Series(rng.normal(0, 1, trials))
y = func(x, n).dropna()
return float(y.var())
print("SMA :", round(noise_gain_empirical(
lambda s, n: s.rolling(n).mean()), 4))
print("EMA :", round(noise_gain_empirical(
lambda s, n: s.ewm(span=n, adjust=False).mean()), 4))
print("WMA :", round(noise_gain_empirical(wma), 4))
print("HMA :", round(noise_gain_empirical(hma), 4))
print("ZLEMA:", round(noise_gain_empirical(zlema), 4))
SMAとEMAが0.05前後で並ぶのに対し、HMAは0.15前後、ZLEMAはさらに大きくなります。ラグを1/3に減らす代わりに、ノイズを3倍受け入れる。これは実装の巧拙ではなく、原理的な交換条件です。だましが増えるのは避けられません。
pandas実装の落とし穴
adjust の既定値は True
s = pd.Series([100, 102, 101, 105, 103, 108], dtype=float)
print(pd.DataFrame({
"adjust=True": s.ewm(span=5, adjust=True).mean(),
"adjust=False": s.ewm(span=5, adjust=False).mean(),
}).round(3))
pandasの既定はadjust=Trueで、これは各時点で重みを再正規化する方式です。一方、チャートツールで表示されるEMAは漸化式そのままのadjust=Falseです。
系列が長くなれば差は消えますが、データの先頭付近では明確に違います。短期間のバックテストや、直近上場銘柄では結果が変わります。チャートと数値を合わせたいならadjust=Falseを明示してください。
EMAには min_periods がない
# SMAは20日揃うまでNaN。EMAは1日目から値が出てしまう
print(s.rolling(20).mean().head(3)) # NaN, NaN, NaN
print(s.ewm(span=20, adjust=False).mean().head(3)) # 値が出る
# EMAも十分なデータが溜まるまで捨てる
ema = s.ewm(span=20, adjust=False, min_periods=20).mean()
EMAは1日目から値を返します。その値は「初日の終値そのもの」なので、指標として意味がありません。ここでゴールデンクロスが誤検出されます。min_periodsを指定して捨ててください。
RSIやATRのEMAは別物
# Wilderの平滑化(RSI, ATR, ADXで使われる)は α = 1/n
def wilder(s: pd.Series, n: int) -> pd.Series:
return s.ewm(alpha=1 / n, adjust=False).mean()
# これは EMA(span=2n-1) と同じ。span=n ではない
n = 14
a = wilder(s, n)
b = s.ewm(span=2 * n - 1, adjust=False).mean()
print("一致:", np.allclose(a.dropna(), b.dropna()))
「14日RSI」の内部平滑化は、EMA(span=14)ではなくEMA(span=27)相当です。ここをspan=14で実装すると、チャートツールのRSIと数値が合いません。RSIの値がMT4と違う、という相談のほとんどがこれです。
クロス戦略で実測してみる
import yfinance as yf
def load(t: str, period: str = "10y") -> pd.Series:
df = yf.download(t, period=period, auto_adjust=True, progress=False)
if isinstance(df.columns, pd.MultiIndex):
df.columns = df.columns.get_level_values(0)
return df["Close"].dropna()
MAS = {
"SMA": lambda s, n: s.rolling(n).mean(),
"EMA": lambda s, n: s.ewm(span=n, adjust=False, min_periods=n).mean(),
"WMA": wma,
"HMA": hma,
}
def cross_test(close: pd.Series, short: int = 20, long: int = 60,
cost: float = 0.001) -> pd.DataFrame:
r = close.pct_change().fillna(0)
rows = []
for name, f in MAS.items():
state = (f(close, short) > f(close, long)).astype(float)
pos = state.shift(1).fillna(0) # 翌日から建てる
trades = pos.diff().abs().fillna(0)
pnl = pos * r - trades * cost
entries = int((pos.diff() > 0).sum())
# 5日以内に手仕舞いになった往復を「だまし」と数える
hold = pos.groupby((pos != pos.shift()).cumsum()).transform("size")
whipsaw = int(((pos.diff() > 0) & (hold <= 5)).sum())
rows.append({
"移動平均": name,
"累積%": round(((1 + pnl).prod() - 1) * 100, 1),
"Sharpe": round(pnl.mean() / pnl.std() * np.sqrt(252), 2),
"エントリー": entries,
"だまし": whipsaw,
"だまし率%": round(whipsaw / max(entries, 1) * 100, 1),
})
return pd.DataFrame(rows).set_index("移動平均")
print(cross_test(load("6758.T")))
実行してみると、SMAとEMAの成績はほとんど変わりません。取引回数もSharpeも誤差の範囲です。理屈どおりで、ラグもノイズも同じなのだから当然でした。
一方でHMAはエントリー回数が明確に増え、だまし率も上がります。速いぶん、レンジ相場で細かく出入りします。コストを0にすれば見栄えは良くなりますが、往復0.1%を入れると逆転することが多い。「速い=良い」ではないことが、ここでもはっきり出ます。
まとめ
数式まで降りて分かったことを整理します。
- ラグは重みの重心。SMA(n)もEMA(span=n)も(n−1)/2日で同じ
- ノイズゲインもどちらも1/n。1次の性質では優劣がない
- 違うのは分布の形。EMAは初動が速く、SMAはn日できっちり決着する
- 本当に速くしたいならHMAやZLEMA。ただしノイズが3倍になる
- pandasでは
adjust=Falseとmin_periodsを明示する - RSIやATRのWilder平滑化はEMA(span=2n−1)相当
「SMAとEMAどっちがいいの」という問いは、実は問いの立て方が違っていました。正しくは「どれだけのラグを、どれだけのノイズと引き換えに受け入れるか」です。この軸で見れば、期間を変えるほうがSMAとEMAを入れ替えるより効きます。
なんとなく使っていたrolling(20).mean()が、こんなに深い話につながっているとは思いませんでした。数式アレルギーで避けていた自分みたいな人ほど、一度手を動かして計算してみる価値があると思います。

