※本記事には広告・アフィリエイトリンクが含まれます。収益はサイト運営・検証費用に充てています。掲載コードは学習・検証目的です。
先に結論です。移動平均乖離率の「±5%で逆張り」という定番の閾値は、銘柄によって意味がまったく違います。ある銘柄では年に3回しか出ないシグナルが、別の銘柄では年に40回出ます。同じ-5%でも、片方は異常事態、もう片方は日常です。
理由は単純で、ボラティリティが違うからです。日々2%動く銘柄と0.7%しか動かない銘柄に、同じものさしを当てているのが無理な話でした。
この記事では、乖離率の計算から始めて、頻度を実測して固定閾値の破綻を確かめ、ボラティリティで正規化した閾値に置き換え、最後に本当に平均回帰するのかを検証します。
📘 外部参考:rolling(pandas公式) / yfinance(GitHub)
乖離率とは(計算式)
現在の株価が移動平均線からどれだけ離れているかをパーセントで表した指標です。
乖離率(%) = (終値 − 移動平均) ÷ 移動平均 × 100
プラスなら移動平均より上(買われすぎ)、マイナスなら下(売られすぎ)とされます。実装は2行です。
import numpy as np
import pandas as pd
import yfinance as yf
def load_close(ticker: str, period: str = "5y") -> pd.Series:
df = yf.download(ticker, period=period, auto_adjust=True, progress=False)
if isinstance(df.columns, pd.MultiIndex):
df.columns = df.columns.get_level_values(0)
s = df["Close"].dropna()
s.index = pd.to_datetime(s.index).tz_localize(None)
return s.sort_index()
def deviation(close: pd.Series, window: int = 25) -> pd.Series:
ma = close.rolling(window).mean()
return (close - ma) / ma * 100
close = load_close("7203.T")
dev = deviation(close)
print(dev.tail(5).round(2))
auto_adjust=Trueを入れておきます。未調整の価格だと、株式分割の日に乖離率が-50%になり、そこが「史上最大の売られすぎ」として記録されます。
固定閾値が破綻することを確かめる
±5%というシグナルが、銘柄ごとに年何回出るかを数えます。
TICKERS = {
"7203.T": "トヨタ",
"9984.T": "SBG",
"6857.T": "アドバンテスト",
"8306.T": "三菱UFJ",
"9432.T": "NTT",
}
def threshold_frequency(tickers: dict, level: float = -5.0, window: int = 25) -> pd.DataFrame:
rows = []
for code, name in tickers.items():
c = load_close(code)
d = deviation(c, window).dropna()
years = (d.index[-1] - d.index[0]).days / 365
below = d <= level
# 連続はまとめて1回と数える
entries = int((below & ~below.shift(1).fillna(False)).sum())
daily_vol = c.pct_change().std(ddof=1) * 100
rows.append({
"name": name,
"daily_vol_pct": round(float(daily_vol), 2),
"dev_std": round(float(d.std(ddof=1)), 2),
"hits_per_year": round(entries / years, 1),
"days_below_pct": round(float(below.mean() * 100), 1),
})
return pd.DataFrame(rows).set_index("name").sort_values("hits_per_year")
print(threshold_frequency(TICKERS))
| 指標 | 意味 | 見方 |
|---|---|---|
| daily_vol_pct | 日次リターンの標準偏差 | 大きいほど-5%に届きやすい |
| dev_std | 乖離率そのもののばらつき | これが閾値の妥当性を決める |
| hits_per_year | 年間シグナル回数 | 銘柄間で10倍近い差が出る |
実行すると、dev_stdが2%台の銘柄と6%台の銘柄が混在します。前者にとって-5%は2標準偏差を超える異常値ですが、後者にとっては1標準偏差にも満たない、ごく普通の値です。同じ閾値で並べて比較すること自体が間違っていました。
ボラティリティで正規化する
解決策は2つあります。乖離率をzスコアに直すか、過去のパーセンタイルで見るかです。
def deviation_zscore(close: pd.Series, window: int = 25, lookback: int = 250) -> pd.Series:
"""乖離率を、過去lookback日の分布での標準得点に直す。"""
d = deviation(close, window)
mu = d.rolling(lookback).mean()
sd = d.rolling(lookback).std(ddof=1)
return (d - mu) / sd
def deviation_percentile(close: pd.Series, window: int = 25, lookback: int = 250) -> pd.Series:
"""過去lookback日の中で下から何%の位置にあるか(0〜1)。"""
d = deviation(close, window)
return d.rolling(lookback).apply(
lambda x: (x < x[-1]).mean(), raw=True
)
z = deviation_zscore(close)
p = deviation_percentile(close)
print(pd.DataFrame({"dev": deviation(close), "z": z, "pct": p}).tail(5).round(3))
zスコアで-2.0を閾値にすれば、どの銘柄でも「その銘柄にとっての異常な下振れ」を拾えます。パーセンタイルなら0.05で「過去1年の下位5%」です。閾値の意味が銘柄によらず一定になるのが利点です。
def normalized_frequency(tickers: dict, z_level: float = -2.0) -> pd.DataFrame:
rows = []
for code, name in tickers.items():
c = load_close(code)
z = deviation_zscore(c).dropna()
years = (z.index[-1] - z.index[0]).days / 365
below = z <= z_level
entries = int((below & ~below.shift(1).fillna(False)).sum())
rows.append({"name": name, "hits_per_year": round(entries / years, 1)})
return pd.DataFrame(rows).set_index("name")
print(normalized_frequency(TICKERS))
固定閾値では10倍あった差が、zスコアだと2〜3倍程度に収まります。完全には揃いませんが、比較可能な範囲になります。
本当に平均回帰するのか
ここが肝心です。乖離率は平均回帰を前提にした指標ですが、実際に戻るのかを測ります。比較対象として全日平均を必ず置きます。
def mean_reversion_test(close: pd.Series, horizon: int = 5,
window: int = 25, lookback: int = 250) -> pd.DataFrame:
z = deviation_zscore(close, window, lookback)
fwd = (close.shift(-horizon) / close - 1) * 100
df = pd.DataFrame({"z": z, "fwd": fwd}).dropna()
bins = [-np.inf, -2, -1, 1, 2, np.inf]
labels = ["z<=-2", "-2〜-1", "-1〜+1", "+1〜+2", "z>=+2"]
df["bucket"] = pd.cut(df["z"], bins=bins, labels=labels)
out = df.groupby("bucket", observed=True)["fwd"].agg(
n="count", mean="mean", median="median",
win_rate=lambda s: (s > 0).mean()
).round(3)
out.loc["全日平均"] = [len(df), df["fwd"].mean().round(3),
df["fwd"].median().round(3), round((df["fwd"] > 0).mean(), 3)]
return out
print(mean_reversion_test(close))
この表の読み方が重要です。z<=-2の行のmeanがプラスでも、全日平均もプラスなら意味がありません。上昇相場では、どの日に買っても5日後はプラスになりがちです。差を見てください。
僕が試した範囲では、大きく下振れした後のリターンは全日平均をやや上回るものの、nが数十件しかなく、差が有意とは言えませんでした。逆にz>=+2の後も下がらないことが多く、「買われすぎだから売り」は特に機能しませんでした。上がっている銘柄は上がり続ける、というだけの話です。
トレンド中は逆張りを止める
平均回帰が効くのはレンジ相場だけです。トレンドが出ている局面では、乖離が広がったまま戻りません。長期移動平均の傾きでフィルタをかけます。
def with_trend_filter(close: pd.Series, long_window: int = 200,
z_level: float = -2.0) -> pd.DataFrame:
z = deviation_zscore(close)
ma_long = close.rolling(long_window).mean()
slope = ma_long.diff(20) # 20日での長期線の傾き
fwd = (close.shift(-5) / close - 1) * 100
df = pd.DataFrame({"z": z, "slope": slope, "fwd": fwd}).dropna()
signal = df["z"] <= z_level
rows = []
for name, mask in [
("シグナル全部", signal),
("上昇トレンド中のみ", signal & (df["slope"] > 0)),
("下降トレンド中のみ", signal & (df["slope"] <= 0)),
]:
sub = df[mask]
if sub.empty:
continue
rows.append({
"case": name, "n": len(sub),
"mean_fwd": round(float(sub["fwd"].mean()), 3),
"win_rate": round(float((sub["fwd"] > 0).mean()), 3),
})
return pd.DataFrame(rows).set_index("case")
print(with_trend_filter(close))
「上昇トレンド中の押し目」と「下降トレンド中の落ちるナイフ」では、同じz=-2でもその後がまったく違います。乖離率は単体で使う指標ではなく、トレンド判定とセットで使うものだと、この表を見て納得しました。
期間の使い分け
移動平均の期間は5日・25日・75日を使い分けるのが一般的です。ただし期間を変えると、乖離率の性質そのものが変わります。
def window_comparison(close: pd.Series, windows=(5, 25, 75, 200)) -> pd.DataFrame:
rows = []
for w in windows:
d = deviation(close, w).dropna()
# 乖離が解消するまでの平均日数(符号が反転するまで)
sign = np.sign(d)
flips = int((sign != sign.shift(1)).sum())
rows.append({
"window": w,
"std": round(float(d.std(ddof=1)), 2),
"max_abs": round(float(d.abs().max()), 1),
"avg_days_to_flip": round(len(d) / flips, 1) if flips else None,
})
return pd.DataFrame(rows).set_index("window")
print(window_comparison(close))
avg_days_to_flipは、乖離の符号が入れ替わるまでの平均日数です。5日線なら数日で戻りますが、200日線だと数か月かかります。短期の乖離は「行き過ぎ」ですが、長期の乖離は「トレンド」です。同じ指標名でも、期間によって読み方を変える必要があります。
チャートに描く
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
def plot_deviation(close: pd.Series, window: int = 25,
path: str = "deviation.png", tail: int = 250) -> str:
ma = close.rolling(window).mean()
d = deviation(close, window)
z = deviation_zscore(close, window)
idx = close.index[-tail:]
fig, (ax1, ax2) = plt.subplots(
2, 1, figsize=(13, 8), sharex=True,
gridspec_kw={"height_ratios": [2, 1]},
)
ax1.plot(idx, close.loc[idx], color="black", linewidth=1, label="終値")
ax1.plot(idx, ma.loc[idx], color="tab:blue", linewidth=1.4, label=f"MA{window}")
ax1.legend(loc="upper left")
ax1.grid(alpha=0.3)
dd = d.loc[idx]
ax2.bar(idx, dd, color=np.where(dd >= 0, "tab:red", "tab:blue"), alpha=0.6)
# 固定の±5%ではなく、その銘柄の±2σを引く
sd = float(d.std(ddof=1))
for level, style in [(2 * sd, "--"), (-2 * sd, "--"), (0, "-")]:
ax2.axhline(level, color="gray", linestyle=style, alpha=0.7)
ax2.set_ylabel(f"乖離率(%) ±2σ={2 * sd:.1f}%")
ax2.grid(alpha=0.3)
fig.tight_layout()
fig.savefig(path, dpi=120)
plt.close(fig)
return path
補助線を±5%固定ではなく±2σにしているのがポイントです。銘柄を切り替えたときに、線の位置が自動で追従します。
まとめ
移動平均乖離率の計算は2行です。難しいのは閾値の決め方でした。
押さえるべきは3点です。±5%のような固定値は、銘柄のボラティリティ次第で頻度が10倍変わる。zスコアかパーセンタイルに正規化すれば、閾値の意味が揃う。そして効果を測るときは、必ず全日平均と比較する。
そのうえで、平均回帰は下降トレンド中には効きませんでした。長期線の傾きでフィルタをかけるだけで、同じシグナルの成績が変わります。売買まで検証したい場合は、バックテスト実装の記事にシグナルを差し替えれば、コスト込みで測れます。
【投資免責事項】本記事で紹介するコードおよび分析結果は情報提供を目的としたものであり、特定の銘柄への投資を推奨するものではありません。投資判断はご自身の責任で行ってください。

