Pythonで移動平均乖離率を計算|固定閾値が効かない理由

株価の行き過ぎを数値で捉える Python実装・コード

※本記事には広告・アフィリエイトリンクが含まれます。収益はサイト運営・検証費用に充てています。掲載コードは学習・検証目的です。

先に結論です。移動平均乖離率の「±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スコアかパーセンタイルに正規化すれば、閾値の意味が揃う。そして効果を測るときは、必ず全日平均と比較する。

そのうえで、平均回帰は下降トレンド中には効きませんでした。長期線の傾きでフィルタをかけるだけで、同じシグナルの成績が変わります。売買まで検証したい場合は、バックテスト実装の記事にシグナルを差し替えれば、コスト込みで測れます。


【投資免責事項】本記事で紹介するコードおよび分析結果は情報提供を目的としたものであり、特定の銘柄への投資を推奨するものではありません。投資判断はご自身の責任で行ってください。

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