SMAとEMAの違い|ラグが実は同じである理由を数式で

基礎知識・戦略

ボリンジャーバンドのコードを書いていたとき、何の疑問もなく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)/29.5日
EMA(span=n)α(1−α)k、α=2/(n+1)(n−1)/29.5日
WMA(n)n, n−1, …, 1 に比例(n−1)/36.3日

SMAとEMAのラグが完全に一致します。偶然ではありません。EMAの平滑化係数がα = 2/(n+1)と定義されているのは、まさに「SMA(n)と同じ重心になるように」逆算した結果だからです。

EMAのラグは(1−α)/αで計算できます。ここにα = 2/(n+1)を代入すると、きれいに(n−1)/2になります。「EMA20はSMA20より速い」という説明は、この意味では正しくありません。

ノイズの減り方も同じ

もう一つ驚いたのがこちらです。入力にホワイトノイズが乗っているとき、出力の分散は元の分散 ×(重みの二乗和)になります。この二乗和を「ノイズゲイン」と呼びます。小さいほど滑らかです。

種類ノイズゲインn=20
SMA(n)1/n0.0500
EMA(span=n)α/(2−α) = 1/n0.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の重みは指数的に減るだけでゼロにはならないため、いつまでも古い価格を引きずります。

SMAEMA
重心(ラグ)(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=Falsemin_periodsを明示する
  • RSIやATRのWilder平滑化はEMA(span=2n−1)相当

「SMAとEMAどっちがいいの」という問いは、実は問いの立て方が違っていました。正しくは「どれだけのラグを、どれだけのノイズと引き換えに受け入れるか」です。この軸で見れば、期間を変えるほうがSMAとEMAを入れ替えるより効きます。

なんとなく使っていたrolling(20).mean()が、こんなに深い話につながっているとは思いませんでした。数式アレルギーで避けていた自分みたいな人ほど、一度手を動かして計算してみる価値があると思います。

タイトルとURLをコピーしました