ボリンジャーバンド戦略を検証|順張りと逆張りどちらが有効か

ボリンジャーバンドで日本株を試した Python実装・コード

先週、ドル円のトレードでまた損切りをした。エントリーのタイミングが悪かったのはわかっているんだけど、何を根拠に「ここだ」と判断すればいいのかがぼんやりしている。それでずっと気になっていたボリンジャーバンドを、自分で実装して数字で確かめてみることにした。

ところが調べはじめて最初にぶつかったのが、同じバンドを使って真逆の戦略が語られていることだった。「+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最大DDSharpe市場滞在率
逆張り(-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桁前半なら結論は出せない。損切りと保有期限がない戦略は評価できない。フォワードリターンはベースラインと比べる。

順張りと逆張りのどちらが正しいのかという最初の疑問には、「バンド幅の水準で意味が変わる」という答えが出た。単独のシグナルとしては弱いが、場面を選ぶフィルターとしてなら使える。他の指標と組み合わせる前提の道具だと思うようになった。

次はバンド幅のランクをフィルターにして、移動平均クロスのエントリーを絞ったらどうなるか試すつもり。

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