※本記事には広告・アフィリエイトリンクが含まれます。収益はサイト運営・検証費用に充てています。掲載コードは学習・検証目的です。
先に結論です。「ボリンジャーバンドの±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.0 | 68.3% | やや高め | 平常時は中心付近に集まる |
| 2.0 | 95.4% | 88〜92% | 外れる頻度は理論の約2倍 |
| 3.0 | 99.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を確認してください。
【投資免責事項】本記事で紹介するコードおよび分析結果は情報提供を目的としたものであり、特定の銘柄への投資を推奨するものではありません。投資判断はご自身の責任で行ってください。

