【Python実装】移動平均乖離率で「売られすぎ」を検知するシンプルロジック

Python実装・コード

※本記事のコードや情報は執筆時点の仕様に基づいています。投資は自己責任であり、必ずデモ環境や少額資金でテストした上で運用してください。

株価データを取得してテクニカル指標を計算する環境はすでに整っている段階ではないでしょうか。

移動平均乖離率(Moving Average Deviation Rate)は、現在の価格が移動平均線からどれだけ離れているかをパーセンテージで示す指標です。逆張り戦略を組み立てるうえで欠かせない判断材料になります。

しかし「乖離率が何%を超えたら売られすぎなのか」「どの期間の移動平均を基準にすべきか」で迷うケースが多く見られます。

原因は、乖離率の計算自体はシンプルなのに、閾値の設定やシグナル判定のロジックを体系的に解説した情報が少ない点にあります。

本記事では、移動平均乖離率の計算式とその意味を整理したうえで、Pythonで「売られすぎ」「買われすぎ」を検知するコードを提示します。さらに、ボリンジャーバンドとの組み合わせによる発展版コードまでカバーします。

コードはすべてコピペで動作する形式で提供しています。閾値や銘柄コードを差し替えるだけで、自分の投資スタイルに合わせたカスタマイズが可能です。

移動平均乖離率の基本概念

計算式と意味

移動平均乖離率(Moving Average Deviation Rate)は、以下の計算式で求めます。

乖離率(%) = (現在の価格 - 移動平均値) / 移動平均値 × 100

乖離率がプラスなら価格が移動平均線より上に位置しており、マイナスなら下に位置していることを意味します。値が大きいほど「平均から離れすぎている=いずれ平均に回帰する可能性が高い」と解釈します。

逆張り戦略における役割

乖離率は逆張り(Contrarian)戦略の中核を担う指標です。一般的な目安として、25日移動平均に対して以下の閾値が参考にされます。

* プラス5%以上:買われすぎゾーンであり、売りシグナルの候補です

* マイナス5%以下:売られすぎゾーンであり、買いシグナルの候補です

* 0%付近:価格が移動平均線に収束しており、トレンドの転換点になりやすい水準です

ただし、この閾値は銘柄のボラティリティ(Volatility:価格変動率)によって大きく変わります。値動きの荒い銘柄ではプラスマイナス10%以上を閾値に設定しないと、シグナルが頻発して実用的ではありません。

閾値をそのまま鵜呑みにするのは危険です。必ず過去データで検証してから運用に移してください。

【コピペOK】移動平均乖離率の計算とシグナル検知コード

まず必要なライブラリをインストールしてください。


pip install yfinance pandas matplotlib

以下がメインコードです。


import yfinance as yf
import pandas as pd
import matplotlib.pyplot as plt
from datetime import datetime

# ==============================
# 設定エリア
# ==============================
TICKER: str = "7203.T"  # 銘柄コード(トヨタ自動車)
START_DATE: str = "2024-01-01"
END_DATE: str = "2025-12-31"
MA_PERIOD: int = 25  # 移動平均の期間(日)
UPPER_THRESHOLD: float = 5.0  # 買われすぎ閾値(%)
LOWER_THRESHOLD: float = -5.0  # 売られすぎ閾値(%)


# ==============================
# データ取得
# ==============================
def fetch_stock_data(ticker: str, start: str, end: str) -> pd.DataFrame:
    '株価データを取得して終値を返す'
    df: pd.DataFrame = yf.download(ticker, start=start, end=end, auto_adjust=True)
    if df.empty:
        raise ValueError(f"データを取得できませんでした: {ticker}")
    # MultiIndex対策でカラムをフラット化
    if isinstance(df.columns, pd.MultiIndex):
        df.columns = df.columns.get_level_values(0)
    return df


# ==============================
# 乖離率計算
# ==============================
def calc_deviation_rate(df: pd.DataFrame, period: int) -> pd.DataFrame:
    '移動平均と乖離率を計算してカラムに追加する'
    df["MA"] = df["Close"].rolling(window=period).mean()
    df["Deviation_Rate"] = ((df["Close"] - df["MA"]) / df["MA"]) * 100
    return df


# ==============================
# シグナル判定
# ==============================
def detect_signals(
    df: pd.DataFrame, upper: float, lower: float
) -> pd.DataFrame:
    '乖離率に基づいて売買シグナルを付与する'
    df["Signal"] = "HOLD"
    df.loc[df["Deviation_Rate"] >= upper, "Signal"] = "SELL"
    df.loc[df["Deviation_Rate"] <= lower, "Signal"] = "BUY"
    return df


# ==============================
# シグナル一覧表示
# ==============================
def print_signals(df: pd.DataFrame) -> None:
    'HOLD以外のシグナルをコンソール出力する'
    signals: pd.DataFrame = df[df["Signal"] != "HOLD"].copy()
    if signals.empty:
        print("シグナルは検出されませんでした。")
        return
    for idx, row in signals.iterrows():
        date_str: str = idx.strftime("%Y-%m-%d") if hasattr(idx, "strftime") else str(idx)
        print(
            f"{date_str} | 終値: {row['Close']:>10.1f} | "
            f"MA({MA_PERIOD}): {row['MA']:>10.1f} | "
            f"乖離率: {row['Deviation_Rate']:>+6.2f}% | "
            f"シグナル: {row['Signal']}"
        )
    print(f"n合計シグナル数: BUY={len(signals[signals['Signal']=='BUY'])} / "
          f"SELL={len(signals[signals['Signal']=='SELL'])}")


# ==============================
# チャート描画
# ==============================
def plot_chart(df: pd.DataFrame, ticker: str, period: int) -> None:
    '株価・移動平均と乖離率を2段チャートで描画する'
    fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(14, 8), sharex=True)

    # 上段:株価と移動平均
    ax1.plot(df.index, df["Close"], label="Close", linewidth=0.9)
    ax1.plot(df.index, df["MA"], label=f"MA({period})", linewidth=0.9)
    buy_df = df[df["Signal"] == "BUY"]
    sell_df = df[df["Signal"] == "SELL"]
    ax1.scatter(buy_df.index, buy_df["Close"], marker="^", color="green", label="BUY", zorder=5)
    ax1.scatter(sell_df.index, sell_df["Close"], marker="v", color="red", label="SELL", zorder=5)
    ax1.set_ylabel("Price (JPY)")
    ax1.set_title(f"{ticker} - Moving Average Deviation Rate Strategy")
    ax1.legend(loc="upper left")
    ax1.grid(True, alpha=0.3)

    # 下段:乖離率
    ax2.plot(df.index, df["Deviation_Rate"], label="Deviation Rate (%)", color="purple", linewidth=0.9)
    ax2.axhline(y=UPPER_THRESHOLD, color="red", linestyle="--", alpha=0.7, label=f"Upper ({UPPER_THRESHOLD}%)")
    ax2.axhline(y=LOWER_THRESHOLD, color="green", linestyle="--", alpha=0.7, label=f"Lower ({LOWER_THRESHOLD}%)")
    ax2.axhline(y=0, color="gray", linestyle="-", alpha=0.3)
    ax2.set_ylabel("Deviation Rate (%)")
    ax2.set_xlabel("Date")
    ax2.legend(loc="upper left")
    ax2.grid(True, alpha=0.3)

    plt.tight_layout()
    plt.savefig("deviation_rate_chart.png", dpi=150)
    plt.show()
    print("チャートを deviation_rate_chart.png に保存しました。")


# ==============================
# メイン処理
# ==============================
if __name__ == "__main__":
    df: pd.DataFrame = fetch_stock_data(TICKER, START_DATE, END_DATE)
    df = calc_deviation_rate(df, MA_PERIOD)
    df = detect_signals(df, UPPER_THRESHOLD, LOWER_THRESHOLD)
    print_signals(df)
    plot_chart(df, TICKER, MA_PERIOD)

コードの処理フロー解説

上記のコードは、以下の5ステップで構成されています。

* データ取得:yfinanceで指定銘柄の株価データをダウンロードし、DataFrameとして格納します

* 乖離率計算:rollingメソッドで移動平均を算出し、計算式に基づいて乖離率カラムを追加します

* シグナル判定:閾値と比較して各行にBUY・SELL・HOLDのラベルを付与します

* 結果出力:HOLD以外のシグナル日をコンソールに一覧表示し、合計件数を集計します

* チャート描画:株価チャートと乖離率チャートを2段構成で描画し、シグナル地点をマーカーで可視化します

MA_PERIODや閾値を変更するだけで、異なる投資スタイルへの適用が可能です。

乖離率シグナルの読み解き方

シグナルの精度を左右する3つの要素

乖離率シグナルの実用性は、以下の要素に大きく依存します。

* 移動平均の期間:短期(5日・10日)は感度が高くノイズも多く、中期(25日・75日)はトレンドを反映しやすい特性があります

* 閾値の設定:ボラティリティの高い銘柄ほど広い閾値が必要です。過去1年の乖離率の標準偏差を基準にするのが実践的な方法です

* 相場環境:強いトレンド相場では乖離率が一方向に張り付くため、逆張りが機能しにくくなります

トレンド相場での注意点

乖離率は本質的にレンジ相場(Range-bound Market:一定幅で推移する相場)向けの指標です。

上昇トレンドが継続する場面では、乖離率がプラス5%を超えたまま上昇し続けることがあります。このとき売りシグナルに従うと、大きな利益を逃す結果になります。

乖離率だけで売買判断を完結させるのは過信です。トレンド判定指標(ADXなど)と併用することを強く推奨します。

【コピペOK】ボリンジャーバンド併用の発展版コード

以下は、乖離率にボリンジャーバンド(Bollinger Bands)のスクイーズ判定を加えた発展版です。メインコードの設定エリアの下に追加して使用してください。


# ==============================
# 設定エリア(発展版追加分)
# ==============================
BB_PERIOD: int = 20  # ボリンジャーバンドの期間
BB_STD_DEV: float = 2.0  # 標準偏差の倍率
SQUEEZE_THRESHOLD: float = 0.03  # バンド幅の収束閾値(3%)


# ==============================
# ボリンジャーバンド計算
# ==============================
def calc_bollinger_bands(df: pd.DataFrame, period: int, std_dev: float) -> pd.DataFrame:
    'ボリンジャーバンドの上限・下限・バンド幅を計算する'
    df["BB_MA"] = df["Close"].rolling(window=period).mean()
    df["BB_STD"] = df["Close"].rolling(window=period).std()
    df["BB_Upper"] = df["BB_MA"] + (df["BB_STD"] * std_dev)
    df["BB_Lower"] = df["BB_MA"] - (df["BB_STD"] * std_dev)
    df["BB_Width"] = (df["BB_Upper"] - df["BB_Lower"]) / df["BB_MA"]
    return df


# ==============================
# 複合シグナル判定
# ==============================
def detect_combined_signals(
    df: pd.DataFrame, upper: float, lower: float, squeeze: float
) -> pd.DataFrame:
    '乖離率シグナル+ボリンジャーバンドのスクイーズ条件で複合判定する'
    df["Combined_Signal"] = "HOLD"

    # スクイーズ状態(バンド幅が収束)かつ乖離率が閾値超えの場合のみシグナル発行
    is_squeeze: pd.Series = df["BB_Width"] <= squeeze
    df.loc[(df["Deviation_Rate"] <= lower) & is_squeeze, "Combined_Signal"] = "STRONG_BUY"
    df.loc[(df["Deviation_Rate"] >= upper) & is_squeeze, "Combined_Signal"] = "STRONG_SELL"

    # スクイーズなしの通常シグナル
    df.loc[
        (df["Deviation_Rate"] <= lower) & ~is_squeeze & (df["Combined_Signal"] == "HOLD"),
        "Combined_Signal",
    ] = "BUY"
    df.loc[
        (df["Deviation_Rate"] >= upper) & ~is_squeeze & (df["Combined_Signal"] == "HOLD"),
        "Combined_Signal",
    ] = "SELL"
    return df


# ==============================
# 発展版メイン処理
# ==============================
if __name__ == "__main__":
    df = fetch_stock_data(TICKER, START_DATE, END_DATE)
    df = calc_deviation_rate(df, MA_PERIOD)
    df = calc_bollinger_bands(df, BB_PERIOD, BB_STD_DEV)
    df = detect_combined_signals(df, UPPER_THRESHOLD, LOWER_THRESHOLD, SQUEEZE_THRESHOLD)

    combined: pd.DataFrame = df[df["Combined_Signal"] != "HOLD"]
    for idx, row in combined.iterrows():
        date_str = idx.strftime("%Y-%m-%d") if hasattr(idx, "strftime") else str(idx)
        print(
            f"{date_str} | 乖離率: {row['Deviation_Rate']:>+6.2f}% | "
            f"バンド幅: {row['BB_Width']:.4f} | "
            f"シグナル: {row['Combined_Signal']}"
        )

コードの処理フロー解説

上記のコードは、以下の3ステップで構成されています。

* ボリンジャーバンド計算:指定期間の移動平均と標準偏差から上限・下限バンドを算出し、バンド幅を正規化して格納します

* 複合シグナル判定:バンド幅が収束(スクイーズ)している状態で乖離率が閾値を超えた場合にSTRONG_BUY/STRONG_SELLを発行します。スクイーズなしの場合は通常のBUY/SELLです

* 結果出力:HOLD以外の複合シグナルをコンソールに表示します

SQUEEZE_THRESHOLDの値を調整することで、シグナルの厳格さをコントロールできます。値を小さくするほど条件が厳しくなり、シグナル数は減少します。

よくあるエラーと対処法

ValueError: データを取得できませんでした

銘柄コードの記述ミスが最も多い原因です。日本株の場合は末尾に.Tを付ける必要があります。

以下を試してください。

* 銘柄コードが正しいか確認する(例:トヨタなら7203.T

* yfinanceのバージョンが古い場合はpip install --upgrade yfinanceで更新する

* ネットワーク接続を確認し、プロキシ環境ではyf.downloadproxy引数を設定する

KeyError: ‘Close’ カラムが見つからない

yfinanceのバージョンによってはDataFrameがMultiIndex形式で返されることがあります。本記事のコードではget_level_values(0)でフラット化していますが、バージョン差異で列名が異なるケースがあります。

以下を試してください。

* print(df.columns)でカラム名を確認する

* auto_adjust=Trueauto_adjust=Falseに変更してAdj Close列を使う

* DataFrameの先頭5行をprint(df.head())で表示し、データ構造を確認する

シグナルが1件も検出されない

閾値の設定が厳しすぎるか、分析期間が短すぎることが原因です。

以下を試してください。

* UPPER_THRESHOLDLOWER_THRESHOLDの絶対値を小さくする(例:5.0→3.0)

* 分析期間を最低1年以上に設定する

* df["Deviation_Rate"].describe()で乖離率の分布を確認し、閾値の妥当性を検証する

まとめ

この記事では、移動平均乖離率の概念からPythonによる実装、ボリンジャーバンドとの複合判定まで解説しました。

要点を整理します。

* 乖離率の計算式は(現在価格 - 移動平均) / 移動平均 × 100であり、平均回帰の度合いを測定する指標です

* 25日移動平均に対してプラスマイナス5%が一般的な閾値の目安ですが、銘柄のボラティリティに応じて調整が必要です

* 乖離率はレンジ相場で有効であり、強いトレンド相場では逆張りシグナルが裏目に出る危険があります

* ボリンジャーバンドのスクイーズ条件と組み合わせることで、シグナルの信頼度を高められます

* 閾値や期間は設定エリアの定数を変更するだけでカスタマイズでき、自分の戦略に合わせた検証が可能です

次のステップとして、ADX(Average Directional Index:平均方向性指数)によるトレンド強度の判定を追加することを推奨します。ADXが25以下のレンジ相場でのみ乖離率シグナルを有効化すれば、トレンド相場での誤シグナルを大幅に抑制できます。

さらに、複数銘柄を一括スキャンして乖離率ランキングを出力するスクリーニングツールへの拡張も有用です。設定エリアのTICKERをリスト化し、ループ処理に変更するだけで実装できます。

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