BacktraderでRSI+MACDを組み合わせた日本株バックテスト【製造メーカー株で検証】

Python実装・コード

先日、backtraderで作ったBOTを初めて動かしてみたのですが、バックテストでは勝率65%だったのに、実運用2週間でマイナスになりました。「なんで?」と思って調べていくうちに、いくつも見落としが見つかりました。

この記事では、Backtraderで製造メーカー株のRSI+MACD戦略を検証した記録をまとめます。Backtraderならではの実装の勘所——複数銘柄の同時運用、実際の約定価格の再現、手数料設定——を中心に、実運用との乖離をどう潰していったかを書いていきます。

戦略ロジックそのものの設計についてはRSI×MACDの逆張り戦略をバックテストする方法にまとめてあるので、ここでは「ライブラリでどう組むか」に絞ります。

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

なぜBacktraderを選んだのか

最初はもっと手軽なライブラリを使っていました。ただ、やりたいことが増えるにつれて限界が来ます。

やりたかったこと手軽なライブラリBacktrader
1銘柄を素早く検証得意やや大げさ
複数銘柄を同時に運用できない得意
資金配分を制御限定的細かく設定できる
成績の詳細分析レポート自動生成アナライザを組み合わせる
証券会社との接続なし対応あり

決め手は複数銘柄の同時運用でした。製造メーカー株を4銘柄まとめて運用したかったのですが、これは1銘柄ずつ検証して合計するのとは意味が違います。

なぜなら資金は共有されているからです。トヨタでポジションを持っている間は、日立でシグナルが出ても資金が足りないかもしれない。この制約を再現できないと、検証結果は現実離れします。

1銘柄の素早い検証ならBacktesting.pyのほうが手軽です。用途で使い分けるのが正解だと思います。

逆に、Backtraderを選ぶうえで承知しておくべき点もあります。

  • 開発は活発ではない: 新しいライブラリとの組み合わせでエラーが出ることがある
  • 学習コストが高い: 最初のコードを動かすまでに覚えることが多い
  • 速度は速くない: 1本ずつ処理するため、数千通りの総当たりには向かない

3つめが問題になる場合は、ベクトル演算で高速に処理するvectorbtという選択肢もあります。「複数銘柄を現実的な制約込みで運用したい」ならBacktrader、「パラメータを大量に試したい」ならvectorbt——この使い分けが実務的です。

それでもBacktraderを推す理由は、実運用に近い形で検証できることに尽きます。資金の制約、約定のタイミング、手数料——これらを1つずつ設定できるので、「検証では勝てたのに実際は違った」という乖離を潰しやすいのです。

📘 外部参考Backtrader 公式ドキュメントGitHubリポジトリ

環境を準備する

pip install backtrader yfinance pandas numpy matplotlib

1つ注意点があります。Backtraderは開発が活発ではなく、新しいバージョンのmatplotlibやpandasと組み合わせるとエラーが出ることがあります。

import backtrader as bt
import pandas as pd
import numpy as np

print(f"backtrader : {bt.__version__}")
print(f"pandas     : {pd.__version__}")

# よくあるエラーの回避策
# 1) matplotlibでのプロットが落ちる場合
#    → bt.plot() を使わず、自分でグラフを描く
# 2) pandas の Timestamp まわりでエラーになる場合
#    → PandasData に渡す前に index を DatetimeIndex に正規化する

プロット機能で詰まる人が多いので、本記事ではBacktraderの描画機能は使わず、結果を自分で取り出してmatplotlibで描く方針にしています。このほうがトラブルが少なく、カスタマイズもしやすくなります。

データを読み込む

import yfinance as yf


def load_feed(ticker, period="5y"):
    """yfinanceのデータをBacktraderが読める形にする"""
    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)

    df = df[["Open", "High", "Low", "Close", "Volume"]].dropna()
    df.index = pd.to_datetime(df.index).tz_localize(None)  # タイムゾーンを外す
    df.columns = [c.lower() for c in df.columns]           # 小文字にする

    return bt.feeds.PandasData(dataname=df)


MAKERS = {
    "7203.T": "トヨタ自動車",
    "6501.T": "日立製作所",
    "6902.T": "デンソー",
    "6367.T": "ダイキン工業",
}

ここで2つの罠があります。1つは列名を小文字にすることPandasDataopen high low close volume という小文字の列を期待します。

もう1つはタイムゾーンを外すこと。yfinanceはタイムゾーン付きのインデックスを返すことがあり、そのままだとBacktrader内部で日付の比較がおかしくなります。tz_localize(None) で外しておくのが安全です。

戦略クラスを実装する

Backtraderでは戦略をクラスとして書きます__init__ で指標を定義し、next() で1日ずつ判断する構造です。

class RsiMacdStrategy(bt.Strategy):
    params = dict(
        rsi_period=14,
        rsi_entry=35,
        rsi_exit=70,
        macd_fast=12,
        macd_slow=26,
        macd_signal=9,
        risk_pct=0.02,      # 1銘柄あたり資金の何割まで
        stop_atr=2.0,       # ATRの何倍で損切りするか
    )

    def __init__(self):
        self.inds = {}
        for d in self.datas:                 # 全銘柄分の指標を用意する
            self.inds[d] = {
                "rsi": bt.indicators.RSI(d.close, period=self.p.rsi_period),
                "macd": bt.indicators.MACD(
                    d.close,
                    period_me1=self.p.macd_fast,
                    period_me2=self.p.macd_slow,
                    period_signal=self.p.macd_signal,
                ),
                "atr": bt.indicators.ATR(d, period=14),
            }
        self.order_log = []

    def next(self):
        for d in self.datas:
            pos = self.getposition(d).size
            ind = self.inds[d]
            hist = ind["macd"].macd[0] - ind["macd"].signal[0]
            hist_prev = ind["macd"].macd[-1] - ind["macd"].signal[-1]

            if not pos:
                # 売られすぎ かつ MACDが好転したら買う
                if ind["rsi"][0] < self.p.rsi_entry and hist > 0 >= hist_prev:
                    size = self._calc_size(d, ind["atr"][0])
                    if size > 0:
                        self.buy(data=d, size=size)
            else:
                # 買われすぎ または MACDが悪化したら手仕舞う
                if ind["rsi"][0] > self.p.rsi_exit or (hist < 0 <= hist_prev):
                    self.close(data=d)

    def _calc_size(self, data, atr):
        """ATRから損切り幅を決め、リスク一定になる株数を返す(100株単位)"""
        if atr <= 0:
            return 0
        risk_yen = self.broker.getvalue() * self.p.risk_pct
        stop_width = atr * self.p.stop_atr
        raw = risk_yen / stop_width
        size = int(raw // 100) * 100

        # 資金が足りるかを確認する
        cost = size * data.close[0]
        if cost > self.broker.getcash() * 0.95:
            size = int(self.broker.getcash() * 0.95
                       / data.close[0] // 100) * 100
        return max(size, 0)

    def notify_order(self, order):
        if order.status == order.Completed:
            self.order_log.append({
                "date": self.datas[0].datetime.date(0),
                "銘柄": order.data._name,
                "売買": "買" if order.isbuy() else "売",
                "価格": round(order.executed.price, 1),
                "株数": order.executed.size,
                "手数料": round(order.executed.comm, 1),
            })

ポイントを3つ挙げておきます。

  • 指標を銘柄ごとに辞書で持つ: self.datas をループして全銘柄分を用意する
  • 株数をATRから逆算する: 銘柄ごとにリスクを揃えるため。100株単位に丸めるのを忘れない
  • 資金の残りを必ず確認する: 複数銘柄を扱う以上、資金切れで発注が失敗しうる

3つめが、僕が最初に見落としていた点です。1銘柄ずつ検証していたときは、常に全資金を使える前提でした。実際には他の銘柄で資金を使っていると、シグナルが出ても買えません。

株数をリスクから逆算する考え方は標準偏差を使ってポジションサイズを決める方法で詳しく扱っています。

実行して結果を取り出す

def run_backtest(tickers, cash=5_000_000, commission=0.001, **kwargs):
    cerebro = bt.Cerebro()

    for code, name in tickers.items():
        feed = load_feed(code)
        cerebro.adddata(feed, name=name)

    cerebro.addstrategy(RsiMacdStrategy, **kwargs)
    cerebro.broker.setcash(cash)
    cerebro.broker.setcommission(commission=commission)

    # 約定価格を「翌日の始値」にする(現実に近い設定)
    cerebro.broker.set_coc(False)

    # 成績を測るアナライザを登録する
    cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name="sharpe",
                        timeframe=bt.TimeFrame.Days, riskfreerate=0.0)
    cerebro.addanalyzer(bt.analyzers.DrawDown, _name="dd")
    cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name="trades")
    cerebro.addanalyzer(bt.analyzers.TimeReturn, _name="timereturn")

    start = cerebro.broker.getvalue()
    results = cerebro.run()
    end = cerebro.broker.getvalue()

    return results[0], start, end


strat, start, end = run_backtest(MAKERS)
print(f"初期資金 : {start:>12,.0f} 円")
print(f"最終資産 : {end:>12,.0f} 円")
print(f"損益     : {end - start:>+12,.0f} 円 "
      f"({(end / start - 1) * 100:+.1f} %)")

set_coc(False) が最も重要な1行です。COCは Cheat-On-Close の略で、これを有効にすると「その日の終値を見て、その日の終値で約定できる」という非現実的な取引になります。

僕が最初に出した勝率65%は、実はこれが原因の一部でした。既定値を確認せずに使うと、ルックアヘッドバイアスが入り込みます。この問題についてはルックアヘッドバイアスをPythonで排除する方法で詳しく扱っています。

アナライザから成績を読む

def summarize(strat, start, end):
    sharpe = strat.analyzers.sharpe.get_analysis()
    dd = strat.analyzers.dd.get_analysis()
    tr = strat.analyzers.trades.get_analysis()

    total = tr.get("total", {}).get("closed", 0)
    won = tr.get("won", {}).get("total", 0)

    print(f"総リターン     : {(end / start - 1) * 100:>8.1f} %")
    print(f"シャープレシオ : {sharpe.get('sharperatio') or 0:>8.2f}")
    print(f"最大DD         : {dd.get('max', {}).get('drawdown', 0):>8.1f} %")
    print(f"売買回数       : {total:>8d}")
    print(f"勝率           : "
          f"{(won / total * 100 if total else 0):>8.1f} %")

    if total:
        avg_win = tr.get("won", {}).get("pnl", {}).get("average", 0)
        avg_loss = tr.get("lost", {}).get("pnl", {}).get("average", 0)
        print(f"平均利益       : {avg_win:>8,.0f} 円")
        print(f"平均損失       : {avg_loss:>8,.0f} 円")
        if avg_loss:
            print(f"損益比         : {abs(avg_win / avg_loss):>8.2f}")

    if total < 30:
        print("\n⚠ 売買回数が少なく、成績は運の影響が大きい状態です")


summarize(strat, start, end)

Backtraderの利点は、アナライザを足すだけで詳細な統計が取れることです。勝率だけでなく損益比(平均利益÷平均損失)も必ず見てください。勝率が低くても損益比が大きければ十分に成立します。

実運用と乖離した3つの原因

ここからが本題です。勝率65%が実運用でマイナスになった原因を、1つずつ潰していきました。

原因1:終値約定になっていた

前述の set_coc です。設定の有無で成績がどれだけ変わるかを比べてみます。

def compare_coc(tickers, cash=5_000_000):
    rows = []
    for coc in (True, False):
        cerebro = bt.Cerebro()
        for code, name in tickers.items():
            cerebro.adddata(load_feed(code), name=name)
        cerebro.addstrategy(RsiMacdStrategy)
        cerebro.broker.setcash(cash)
        cerebro.broker.setcommission(commission=0.001)
        cerebro.broker.set_coc(coc)
        cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name="trades")

        res = cerebro.run()[0]
        tr = res.analyzers.trades.get_analysis()
        total = tr.get("total", {}).get("closed", 0)
        won = tr.get("won", {}).get("total", 0)

        rows.append({
            "設定": "終値約定(COC=True)" if coc else "翌日始値(COC=False)",
            "最終資産": round(cerebro.broker.getvalue()),
            "リターン%": round((cerebro.broker.getvalue() / cash - 1) * 100, 1),
            "売買": total,
            "勝率%": round(won / total * 100 if total else 0, 1),
        })
    return pd.DataFrame(rows)


print(compare_coc(MAKERS).to_string(index=False))

この差が、「実現できない利益」の大きさです。COCを有効にすると勝率が10ポイント以上高く出ることも珍しくありません。

原因2:スリッページを入れていなかった

手数料は設定していましたが、スリッページを0のままにしていました。実際には、成行注文は想定価格ぴったりでは約定しません。

def compare_slippage(tickers, cash=5_000_000):
    rows = []
    for slip in (0.0, 0.0005, 0.001, 0.002):
        cerebro = bt.Cerebro()
        for code, name in tickers.items():
            cerebro.adddata(load_feed(code), name=name)
        cerebro.addstrategy(RsiMacdStrategy)
        cerebro.broker.setcash(cash)
        cerebro.broker.setcommission(commission=0.001)
        cerebro.broker.set_coc(False)
        cerebro.broker.set_slippage_perc(perc=slip)   # ここを設定する

        cerebro.run()
        value = cerebro.broker.getvalue()
        rows.append({
            "スリッページ": f"{slip * 100:.2f}%",
            "最終資産": round(value),
            "リターン%": round((value / cash - 1) * 100, 1),
        })
    return pd.DataFrame(rows)


print(compare_slippage(MAKERS).to_string(index=False))

売買回数が多い戦略ほど、この影響は効いてきます。0.1%のスリッページでも、100回売買すれば10%の目減りです。コストの見積もり方は手数料・スリッページをPythonで組み込む方法にまとめています。

原因3:検証期間がたまたま良かった

これが最も根本的な問題でした。5年分をまとめて検証していたので、その期間の相場に合っていただけだったのです。

# 年ごとに区切って成績を見る
timereturn = strat.analyzers.timereturn.get_analysis()
tr_series = pd.Series(timereturn)
tr_series.index = pd.to_datetime(tr_series.index)

by_year = tr_series.groupby(tr_series.index.year).apply(
    lambda s: round(float((1 + s).prod() - 1) * 100, 1))

print("年別リターン(%)")
print(by_year.to_string())
print(f"\nプラスの年: {int((by_year > 0).sum())} / {len(by_year)} 年")

if (by_year > 0).mean() < 0.6:
    print("⚠ 特定の年に依存しています。安定した戦略とは言えません")

年別に分解すると、1年だけ突出して良く、他の年は横ばいという結果でした。これでは実運用の2週間がたまたま悪かったのではなく、そもそも安定していなかったということになります。

この問題を体系的に検証する手法がウォークフォワード分析です。期間をずらしながら繰り返し検証することで、特定の期間への依存を炙り出せます。

現実的な設定でやり直す

3つの原因を踏まえて、設定を実運用に近づけて再検証します。

def realistic_backtest(tickers, cash=5_000_000):
    """実運用に近い設定でのバックテスト"""
    cerebro = bt.Cerebro()
    for code, name in tickers.items():
        cerebro.adddata(load_feed(code), name=name)

    cerebro.addstrategy(RsiMacdStrategy)
    cerebro.broker.setcash(cash)
    cerebro.broker.setcommission(commission=0.001)   # 片道0.1%
    cerebro.broker.set_coc(False)                    # 翌日始値で約定
    cerebro.broker.set_slippage_perc(perc=0.0005)    # スリッページ0.05%

    for name, analyzer in [
        ("sharpe", bt.analyzers.SharpeRatio),
        ("dd", bt.analyzers.DrawDown),
        ("trades", bt.analyzers.TradeAnalyzer),
        ("timereturn", bt.analyzers.TimeReturn),
    ]:
        cerebro.addanalyzer(analyzer, _name=name)

    res = cerebro.run()[0]
    return res, cash, cerebro.broker.getvalue()


strat2, start2, end2 = realistic_backtest(MAKERS)
print("=== 現実的な設定での結果 ===")
summarize(strat2, start2, end2)

結果は当然ながら地味になります。ただ、この数字なら実運用でも再現できる——そこが決定的に違います。最初の勝率65%は、そもそも実現不可能な数字でした。

複数銘柄ならではの注意点

同じ業種ばかりだと分散にならない

製造メーカー4銘柄で組んでいましたが、これは分散しているようで、していません。同じ業種は同じ材料で動くため、下げるときは揃って下げます。

# 保有銘柄の相関を確認する
prices = yf.download(list(MAKERS), period="3y", auto_adjust=True,
                     progress=False)["Close"].dropna()
corr = prices.pct_change().dropna().corr()
print(corr.round(2).to_string())

avg_corr = float(corr.values[np.triu_indices_from(corr.values, k=1)].mean())
print(f"\n平均相関: {avg_corr:.3f}")
if avg_corr > 0.6:
    print("⚠ 相関が高く、分散効果は限定的です")

相関の確認方法は相関係数を計算して銘柄間の関係を数値化する方法で詳しく扱っています。業種を分けるだけでも、平均相関はかなり下がります。

シグナルが同時に出ると資金が足りない

相関が高い銘柄を並べると、シグナルも同じタイミングで出ます。全銘柄で同時に買いシグナルが出たとき、資金が足りずに一部しか買えない——という事態が起こります。

これは検証上のバグではなく、実際に起きる制約です。ただしどの銘柄を優先するかで結果が変わるため、ルールを決めておく必要があります。

  • RSIが最も低い銘柄を優先: シグナルの強さで選ぶ
  • あらかじめ銘柄ごとに枠を決める: 資金を4等分して独立させる
  • 同時保有数に上限を設ける: 最大2銘柄までなど

僕は2番目を採用しました。実装がシンプルで、検証結果も安定するからです。銘柄ごとに独立した口座があるイメージで運用しています。

まとめ

BacktraderでRSI+MACD戦略を検証し、実運用との乖離を潰していった記録をまとめました。

  • Backtraderの利点は複数銘柄の同時運用と資金制約の再現
  • データ読み込みでは列名の小文字化とタイムゾーン除去を忘れない
  • set_coc(False) が最重要。終値約定はルックアヘッドそのもの
  • set_slippage_perc() も必須。手数料だけでは足りない
  • アナライザで勝率だけでなく損益比も確認する
  • 年別に分解して、特定の期間に依存していないかを見る
  • 同業種を並べても分散にならない。相関を数字で確認する

いま振り返ると、最初の「勝率65%」は設定を確認しなかっただけの話でした。ライブラリは便利ですが、既定値が自分の想定と一致しているとは限りません。

特にBacktraderは設定項目が多いぶん、知らないうちに非現実的な条件で検証していることが起こりえます。set_cocset_slippage_perc の2つだけは、必ず明示的に指定する習慣をつけてください。それだけで、実運用との乖離はかなり小さくなります。

もう1つ学んだのは、「実運用でズレたら、まず自分の設定を疑う」という順序です。最初は「相場が変わったのかも」と外部要因を考えましたが、実際は自分の検証環境が甘かっただけでした。相場のせいにするのは、手元を全部確認してからでも遅くありません。

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