先週、ドル円のトレードでまた損切りをした。エントリーのタイミングが悪かったのはわかっているんだけど、何を根拠に「ここだ」と判断すればいいのかがぼんやりしている。それでずっと気になっていたボリンジャーバンドを、自分で実装して数字で確かめてみることにした。
ところが調べはじめて最初にぶつかったのが、同じバンドを使って真逆の戦略が語られていることだった。「+2σを超えたら買い(ブレイクアウト=順張り)」と「+2σを超えたら売り(行き過ぎ=逆張り)」が、どちらも当然のように紹介されている。両方が正しいことはありえない。
そこで、両方を同じ条件でバックテストして比べた。結論から書くと、日本株の日足では、素の逆張りも素の順張りもBuy&Holdに勝てなかった。ただ、その負け方に違いがあって、そこが面白かった。
📘 関連:バンドの計算式そのものや「2σに95%収まる」という説明の検証はボリンジャーバンドの計算記事にまとめた。
まず、最初に書いたコードが間違っていた
自分が最初に書いたバックテストはこうだった。ぱっと見は問題なさそうに見える。
for i in range(window, len(close)):
price = close.iloc[i]
if position == 0 and price < lower.iloc[i]:
position = 1
entry_price = price # ← この行が問題
その日の終値を見てシグナルを判定し、同じ終値で約定している。終値は大引けにならないと確定しない。確定した瞬間にその値段で買うことはできない。これは未来参照(ルックアヘッドバイアス)そのもので、成績が実際より良く出る。
直すには、シグナルを1日ずらして翌日の始値で約定させる。ここを直しただけで、勝率は数ポイント落ちた。
データ取得とバンドの計算
import numpy as np
import pandas as pd
import yfinance as yf
def load(ticker: str, start: str = "2015-01-01") -> pd.DataFrame:
df = yf.download(ticker, start=start, auto_adjust=True, progress=False)
if isinstance(df.columns, pd.MultiIndex):
df.columns = df.columns.get_level_values(0)
return df[["Open", "High", "Low", "Close"]].dropna()
def bands(close: pd.Series, window: int = 20, k: float = 2.0) -> pd.DataFrame:
ma = close.rolling(window).mean()
sd = close.rolling(window).std(ddof=0) # チャートツールに合わせて母集団標準偏差
return pd.DataFrame({
"ma": ma,
"upper": ma + k * sd,
"lower": ma - k * sd,
"bw": (2 * k * sd) / ma, # バンド幅(ボラティリティの代理)
"pctb": (close - (ma - k * sd)) / (2 * k * sd), # %B
})
df = load("7203.T")
bb = bands(df["Close"])
print(bb.tail(3))
ddof=0にしている理由は、TradingViewやMT系のチャートが母集団標準偏差を使っているから。pandasの既定はddof=1なので、そのままだとチャートの線と数値が微妙に合わない。20日窓なら誤差は小さいが、閾値ちょうどのシグナルで判定が変わることがある。
順張りと逆張りを同じ土俵で比べる
ポジションを日次のベクトルとして持ち、シグナルをshift(1)してから翌日リターンに掛ける形にした。ループで書くより未来参照が混ざりにくい。
def run(df: pd.DataFrame, mode: str, window: int = 20, k: float = 2.0,
cost: float = 0.001) -> dict:
"""mode='revert'(逆張り) / 'breakout'(順張り)"""
close = df["Close"]
b = bands(close, window, k)
if mode == "revert":
entry = close < b["lower"] # 下限割れで買い
exit_ = close >= b["ma"] # 中心線回帰で手仕舞い
elif mode == "breakout":
entry = close > b["upper"] # 上限突破で買い
exit_ = close <= b["ma"] # 中心線割れで手仕舞い
else:
raise ValueError(mode)
# エントリー/イグジットをステートに変換(保有=1, 非保有=0)
state = pd.Series(np.nan, index=close.index)
state[entry] = 1.0
state[exit_] = 0.0
state = state.ffill().fillna(0.0)
pos = state.shift(1).fillna(0.0) # 翌日から建玉を持つ
ret = close.pct_change().fillna(0.0)
trades = pos.diff().abs().fillna(0.0) # 建玉が変わった日
strat = pos * ret - trades * cost
eq = (1 + strat).cumprod()
n_trades = int((pos.diff() > 0).sum())
years = len(close) / 252
return {
"mode": mode,
"cagr_pct": round((eq.iloc[-1] ** (1 / years) - 1) * 100, 2),
"mdd_pct": round((eq / eq.cummax() - 1).min() * 100, 2),
"sharpe": round(strat.mean() / strat.std() * np.sqrt(252), 2),
"trades": n_trades,
"exposure_pct": round(pos.mean() * 100, 1),
}
rows = [run(df, "revert"), run(df, "breakout")]
bh = (1 + df["Close"].pct_change().fillna(0)).cumprod()
years = len(df) / 252
rows.append({
"mode": "buy&hold",
"cagr_pct": round((bh.iloc[-1] ** (1 / years) - 1) * 100, 2),
"mdd_pct": round((bh / bh.cummax() - 1).min() * 100, 2),
"sharpe": round(df["Close"].pct_change().mean()
/ df["Close"].pct_change().std() * np.sqrt(252), 2),
"trades": 1,
"exposure_pct": 100.0,
})
print(pd.DataFrame(rows).set_index("mode"))
トヨタの10年分で回すと、だいたいこういう傾向になった(期間や銘柄で数値は変わるので、形として見てほしい)。
| 戦略 | CAGR | 最大DD | Sharpe | 市場滞在率 |
|---|---|---|---|---|
| 逆張り(-2σ買い) | 低い | 浅い | やや低い | 20〜30% |
| 順張り(+2σ買い) | 低い | 中程度 | 低い | 25〜35% |
| Buy&Hold | 高い | 深い | 中程度 | 100% |
ここで見てほしいのは市場滞在率だ。バンド系の戦略は3割前後しか市場にいない。それでBuy&Holdの半分のリターンが出ているなら、単位時間あたりの効率は悪くない。ただ、残り7割の期間の資金をどうするかという問題が別に発生する。1銘柄だけで運用する前提だと、この時間はただ遊んでいる。
「トレード数18回」で判断してはいけない
最初に2年分で回したときはトレード数18回で勝率61%だった。悪くないと思ったが、これは何も言っていないに等しい。18回中11勝は、公平なコインでも十分起こる。
from scipy import stats
def is_meaningful(wins: int, n: int) -> None:
"""勝率が50%より有意に高いか(片側二項検定)"""
p = stats.binomtest(wins, n, 0.5, alternative="greater").pvalue
print(f"{wins}/{n} 勝率={wins/n:.1%} p={p:.3f} "
+ ("有意" if p < 0.05 else "偶然の範囲"))
is_meaningful(11, 18) # 2年分 → 偶然の範囲
is_meaningful(58, 95) # 10年分 → ようやく議論できる
勝率だけでなく、そもそも勝率は戦略の良し悪しを決めない。逆張りは小さく何度も勝って、たまに大きく負ける形になりやすい。中心線回帰まで待つ設計だと、下げ続ける銘柄では回帰が来ないまま損失が膨らむ。勝率が高いのに資産が減る典型がこれだ。
損切りと保有期限を入れる
上の実装には「回帰しなかったとき」の出口がない。実務では必ず入れる。
def run_with_stop(df: pd.DataFrame, window: int = 20, k: float = 2.0,
stop_pct: float = 0.05, max_hold: int = 20,
cost: float = 0.001) -> dict:
"""逆張り+損切り+保有期限。約定は翌日始値。"""
o, c = df["Open"], df["Close"]
b = bands(c, window, k)
entry_sig = (c < b["lower"]).shift(1).fillna(False) # 前日シグナル→当日寄付
pos, entry_px, held = 0, 0.0, 0
trades = []
for i in range(window + 1, len(df)):
px_open, px_close = float(o.iloc[i]), float(c.iloc[i])
if pos == 1:
held += 1
hit_stop = px_close <= entry_px * (1 - stop_pct)
hit_ma = px_close >= float(b["ma"].iloc[i])
timeout = held >= max_hold
if hit_stop or hit_ma or timeout:
pnl = (px_close / entry_px - 1) - 2 * cost
trades.append({
"exit_date": df.index[i], "pnl": pnl, "held": held,
"reason": "stop" if hit_stop else ("ma" if hit_ma else "timeout"),
})
pos, held = 0, 0
elif entry_sig.iloc[i]:
pos, entry_px, held = 1, px_open, 0
t = pd.DataFrame(trades)
if t.empty:
return {}
return {
"trades": len(t),
"win_rate": round((t["pnl"] > 0).mean() * 100, 1),
"avg_pnl_pct": round(t["pnl"].mean() * 100, 2),
"total_pct": round(((1 + t["pnl"]).prod() - 1) * 100, 2),
"avg_hold": round(t["held"].mean(), 1),
"by_reason": t.groupby("reason")["pnl"].agg(["count", "mean"]).round(4).to_dict(),
}
import pprint; pprint.pp(run_with_stop(df))
by_reasonを見ると設計の欠陥がわかる。timeoutでの決済が多く、その平均損益がマイナスなら、中心線回帰という前提が成り立っていない。自分のケースでは、下降トレンド中のエントリーがほぼ全部timeoutで終わっていた。
バンド幅で場面を選ぶ
実装して初めて意識したのは、バンドの幅そのものが情報だということ。値動きが荒いほど広がるので、幅は事後的なボラティリティ指標になっている。
そして、バンドが狭いとき(スクイーズ)と広いときでは、同じ「2σタッチ」の意味が違う。バンドが狭いときの突破は本物のブレイクになりやすく、広いときの突破は行き過ぎのことが多い。順張りと逆張りが両方語られる理由はここにあった。
def by_bandwidth(df: pd.DataFrame, window: int = 20, k: float = 2.0,
horizon: int = 10) -> pd.DataFrame:
"""バンド幅の水準別に、上限突破後のリターンを見る。"""
c = df["Close"]
b = bands(c, window, k)
fwd = c.shift(-horizon) / c - 1
touch = c > b["upper"]
# バンド幅を過去1年の中での順位に変換(銘柄間で比較できるようにする)
rank = b["bw"].rolling(252).rank(pct=True)
tbl = pd.DataFrame({"fwd": fwd, "rank": rank})[touch].dropna()
tbl["band"] = pd.cut(tbl["rank"], [0, 0.33, 0.66, 1.0],
labels=["狭い", "普通", "広い"])
out = tbl.groupby("band", observed=True)["fwd"].agg(["count", "mean", "median"])
out[["mean", "median"]] = (out[["mean", "median"]] * 100).round(2)
out["baseline_mean"] = round(fwd.mean() * 100, 2)
return out
print(by_bandwidth(df))
baseline_mean(全期間の平均フォワードリターン)を併記しているのが要点だ。「上限突破後の10日リターンが+1.2%」と言われても、その銘柄の平均が+1.0%なら、シグナルはほぼ何もしていない。常にベースラインと比べる。
パラメータを変えて確かめる
def sweep(df: pd.DataFrame, mode: str) -> pd.DataFrame:
rows = []
for w in (10, 20, 40, 60):
for k in (1.5, 2.0, 2.5, 3.0):
r = run(df, mode, window=w, k=k)
r.update({"window": w, "k": k})
rows.append(r)
return (pd.DataFrame(rows)
.pivot(index="window", columns="k", values="sharpe"))
print("逆張り\n", sweep(df, "revert"))
print("順張り\n", sweep(df, "breakout"))
表にして眺めると、良い数字が1マスだけ孤立していることが多い。隣のマスが軒並みマイナスなら、その1マスはただのノイズだ。面で良い領域があるかどうかを見る。
やってみてわかったこと
ボリンジャーバンドをゼロから書いてみて得たものは、戦略そのものより検証の作法だった。終値でシグナルを出して終値で約定してはいけない。トレード数が2桁前半なら結論は出せない。損切りと保有期限がない戦略は評価できない。フォワードリターンはベースラインと比べる。
順張りと逆張りのどちらが正しいのかという最初の疑問には、「バンド幅の水準で意味が変わる」という答えが出た。単独のシグナルとしては弱いが、場面を選ぶフィルターとしてなら使える。他の指標と組み合わせる前提の道具だと思うようになった。
次はバンド幅のランクをフィルターにして、移動平均クロスのエントリーを絞ったらどうなるか試すつもり。

