Pythonでボリンジャーバンドを計算|2σ95%の誤解を検証

Python実装・コード

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

先に結論です。「ボリンジャーバンドの±2σに価格の約95%が収まる」という説明は、実際のデータで測ると成り立ちません。日本株の日足で測ると、だいたい88〜92%あたりに落ち着きます。5%どころか10%前後がバンドの外にいます。

理由は2つあります。株価リターンの分布が正規分布より裾が厚いこと、そして移動平均と標準偏差が20日窓で動き続けていることです。95%という数字は、静的な正規分布を前提にした話で、ローリング計算のバンドには当てはまりません。

この記事では、pandasでの実装から始めて、自分のデータで含有率を実測するコード、チャートツールと数字がズレる原因、そしてスクイーズ後に本当に大きく動くのかの検証まで書きます。

📘 外部参考rolling.std(pandas公式)yfinance(GitHub)

ボリンジャーバンドの計算

移動平均を中心に、上下へ標準偏差の倍数だけ離した線を引きます。一般的な設定は20日・2σです。

  • 中央線(Middle):過去20日の終値の単純移動平均
  • 上限線(Upper):Middle + 標準偏差 × 2
  • 下限線(Lower):Middle − 標準偏差 × 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 bollinger(close: pd.Series, window: int = 20, k: float = 2.0, ddof: int = 0) -> pd.DataFrame:
    mid = close.rolling(window).mean()
    sd = close.rolling(window).std(ddof=ddof)
    df = pd.DataFrame({
        "close": close,
        "middle": mid,
        "upper": mid + sd * k,
        "lower": mid - sd * k,
        "sigma": sd,
    })
    df["bandwidth"] = (df["upper"] - df["lower"]) / df["middle"]
    # %B:0でLower、1でUpper、範囲外は0未満/1超になる
    df["percent_b"] = (df["close"] - df["lower"]) / (df["upper"] - df["lower"])
    return df

close = load_close("7203.T")
bb = bollinger(close)
print(bb.tail(3).round(2))

ddofでチャートツールと数字がズレる

ここが地味に効きます。pandasのrolling().std()デフォルトがddof=1(標本標準偏差、n-1で割る)ですが、多くのチャートツールは母標準偏差(nで割る)を使っています。同じ「20日2σ」でも線の位置が微妙に違います。

def ddof_gap(close: pd.Series, window: int = 20) -> pd.DataFrame:
    s0 = close.rolling(window).std(ddof=0)
    s1 = close.rolling(window).std(ddof=1)
    diff = (s1 - s0).dropna()
    return pd.DataFrame([{
        "mean_sigma": round(float(s0.mean()), 3),
        "mean_gap": round(float(diff.mean()), 3),
        "gap_pct": round(float((diff / s0.dropna()).mean() * 100), 3),
        "theory_pct": round((np.sqrt(window / (window - 1)) - 1) * 100, 3),
    }])

print(ddof_gap(close))

20日窓なら理論値でおよそ2.6%の差です。バンド幅にすると無視できる大きさではありません。上のbollinger関数でddof=0を既定にしているのは、チャートと合わせるためです。「自分の計算と証券会社のチャートが合わない」の原因は、たいていこれです。

「95%が収まる」を実測する

本題です。バンドの中に価格が何%入っているかを、実データで数えます。

def containment(close: pd.Series, window: int = 20, ks=(1.0, 1.5, 2.0, 2.5, 3.0)) -> pd.DataFrame:
    rows = []
    for k in ks:
        bb = bollinger(close, window=window, k=k).dropna()
        inside = ((bb["close"] <= bb["upper"]) & (bb["close"] >= bb["lower"])).mean()
        # 正規分布ならこうなるはず、という理論値
        from math import erf, sqrt
        theory = erf(k / sqrt(2))
        rows.append({
            "k": k,
            "actual_pct": round(float(inside) * 100, 2),
            "normal_theory_pct": round(theory * 100, 2),
            "gap": round(float(inside) * 100 - theory * 100, 2),
        })
    return pd.DataFrame(rows).set_index("k")

print(containment(close))

2σの行を見てください。理論値95.45%に対して、実測は88〜92%程度になるはずです。差の3〜7ポイントは、およそ年間10〜17営業日ぶんの「想定外」に相当します。逆張りでバンドタッチを拾う戦略を組むなら、この頻度の見積もりを間違えると資金管理が崩れます。

k(σ)正規分布の理論値日本株の実測イメージ意味
1.068.3%やや高め平常時は中心付近に集まる
2.095.4%88〜92%外れる頻度は理論の約2倍
3.099.7%97〜99%裾が厚い。3σ超えも普通に起きる

この形は「中心は理論より詰まっていて、裾は理論より外れる」という、金融データの典型です。正規分布と株価リターンの現実でも同じ性質を扱っています。

%Bとバンド幅を使う

バンドの絶対位置より、正規化した%Bの方が銘柄をまたいで比較できます。0でLower、1でUpper、0.5で中心です。

def percent_b_summary(close: pd.Series) -> dict:
    bb = bollinger(close).dropna()
    pb = bb["percent_b"]
    return {
        "now": round(float(pb.iloc[-1]), 3),
        "over_1_pct": round(float((pb > 1).mean() * 100), 2),   # Upper超え
        "under_0_pct": round(float((pb < 0).mean() * 100), 2),  # Lower割れ
        "median": round(float(pb.median()), 3),
    }

print(percent_b_summary(close))

上抜けと下抜けの比率が偏っているなら、その銘柄はその期間トレンドを持っていたということです。上抜けが多い銘柄で「Upperタッチは売り」と決め打つと、上昇に逆らい続けることになります。

スクイーズの後、本当に大きく動くのか

「バンドが収縮したあとは大きく動く」とよく言われます。これも測れます。バンド幅が過去の下位20%に入った日を起点に、その後20日間の値動きの大きさを比べます。

def squeeze_test(close: pd.Series, lookback: int = 250, horizon: int = 20,
                 quantile: float = 0.2) -> pd.DataFrame:
    bb = bollinger(close).dropna()
    bw = bb["bandwidth"]
    threshold = bw.rolling(lookback).quantile(quantile)
    is_squeeze = bw < threshold

    fwd_move = (close.shift(-horizon) / close - 1).abs()   # 先の値動きの大きさ
    fwd_move = fwd_move.reindex(bb.index)

    rows = []
    for name, mask in [("squeeze", is_squeeze), ("normal", ~is_squeeze)]:
        sub = fwd_move[mask].dropna()
        if len(sub) == 0:
            continue
        rows.append({
            "state": name,
            "n": len(sub),
            "mean_abs_move_pct": round(float(sub.mean() * 100), 2),
            "median_pct": round(float(sub.median() * 100), 2),
            "p90_pct": round(float(sub.quantile(0.9) * 100), 2),
        })
    return pd.DataFrame(rows).set_index("state")

print(squeeze_test(close))

ここで絶対値を取っているのが要点です。スクイーズが教えてくれるのは「大きく動く」であって「上に動く」ではありません。方向を予測する指標ではないので、方向つきのリターンで検証すると何も出ません。

実際に回すと、squeeze側の平均が少し大きい程度で、劇的な差にはならないことが多いです。それでも、ボラティリティが上がりやすい局面を事前に知れるのは、ポジションサイズを絞る判断には使えます。

逆張りと順張り、どちらが機能するか

バンドタッチには相反する2つの解釈があります。「行き過ぎだから戻る」(逆張り)と「バンドウォークの始まり」(順張り)です。同じ土俵で比べます。

def touch_forward_return(close: pd.Series, horizon: int = 5) -> pd.DataFrame:
    bb = bollinger(close).dropna()
    fwd = (close.shift(-horizon) / close - 1).reindex(bb.index)

    upper_touch = bb["close"] >= bb["upper"]
    lower_touch = bb["close"] <= bb["lower"]

    rows = []
    for name, mask in [("upper_touch", upper_touch), ("lower_touch", lower_touch),
                       ("all_days", pd.Series(True, index=bb.index))]:
        sub = fwd[mask].dropna()
        if len(sub) == 0:
            continue
        rows.append({
            "case": name,
            "n": len(sub),
            "mean_fwd_pct": round(float(sub.mean() * 100), 3),
            "win_rate": round(float((sub > 0).mean()), 3),
        })
    return pd.DataFrame(rows).set_index("case")

print(touch_forward_return(close))

比較対象にall_daysを必ず入れてください。Lowerタッチ後の5日リターンがプラスでも、全日平均もプラスなら、それは相場が上がっただけです。タッチ条件に意味があるのは、全日平均を明確に上回るときだけです。この基準を置かないと、上昇相場ではどんな買いシグナルも「機能している」ように見えます。

本格的に売買まで検証するなら、コストとシグナル遅延を含めた枠組みが必要です。バックテスト実装の記事の関数がそのまま使えます。

チャートに描く

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt

def plot_bollinger(bb: pd.DataFrame, path: str = "bollinger.png", tail: int = 250) -> str:
    d = bb.dropna().tail(tail)
    fig, (ax1, ax2) = plt.subplots(
        2, 1, figsize=(13, 8), sharex=True,
        gridspec_kw={"height_ratios": [3, 1]},
    )
    ax1.plot(d.index, d["close"], color="black", linewidth=1, label="Close")
    ax1.plot(d.index, d["middle"], color="tab:blue", linewidth=1.2, label="MA20")
    ax1.plot(d.index, d["upper"], color="tab:red", linestyle="--", linewidth=0.9)
    ax1.plot(d.index, d["lower"], color="tab:green", linestyle="--", linewidth=0.9)
    ax1.fill_between(d.index, d["upper"], d["lower"], alpha=0.08, color="tab:blue")
    ax1.legend(loc="upper left")
    ax1.grid(alpha=0.3)

    ax2.plot(d.index, d["bandwidth"], color="tab:purple", linewidth=1)
    ax2.set_ylabel("BandWidth")
    ax2.grid(alpha=0.3)

    fig.tight_layout()
    fig.savefig(path, dpi=120)
    plt.close(fig)
    return path

plot_bollinger(bb)

バンド幅を下段に並べるのがおすすめです。価格チャートだけだと収縮に気づきにくいですが、下段が谷を作っている箇所を見れば一目で分かります。

まとめ

ボリンジャーバンドの計算自体はrolling().mean()rolling().std()の2行です。難しいのは、出てきた線をどう解釈するかでした。

押さえるべきは3点です。ddof=0にしてチャートと数字を合わせる。「±2σに95%」を鵜呑みにせず、自分のデータで含有率を測る(実測は88〜92%)。タッチの効果を検証するときは、必ず全日平均と比べる。

次の一歩としては、RSIとの組み合わせで条件を絞る方向があります。ただし条件を足すほどサンプル数が減り、たまたま良い数字が出やすくなります。条件を1つ足したら、必ずnを確認してください。


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

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