【Python実装】Backtesting.pyで日本株のSMAクロス戦略を1本のスクリプトで検証する完全ガイド

Python実装・コード

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

売買ルールを思いついたとき、それが過去に通用したのかを確かめたい——バックテストの目的はこれに尽きます。ただ、自分でゼロから検証コードを書くと、手数料の計上漏れやシグナルのずらし忘れといったバグが必ず混入します。

そこで使いたいのがBacktesting.pyです。1本のスクリプトで、データ取得から成績レポートまで完結する手軽さが持ち味で、個人が最初に触るバックテストライブラリとして最も扱いやすい選択肢だと思います。

この記事では、日本株のSMAクロス戦略を題材に、インストールから結果の読み方、そして最適化の落とし穴までを一通り解説します。他のライブラリとの使い分けにも触れるので、「結局どれを使えばいいのか」の判断材料にもなるはずです。

Backtesting.pyを選ぶ理由

Pythonのバックテストライブラリは複数ありますが、それぞれ設計思想が違います。目的に合わないものを選ぶと、ひたすら遠回りすることになります。

ライブラリ設計得意なこと不得意なこと
Backtesting.pyイベント駆動手軽な検証とレポート複数銘柄の同時運用
Backtraderイベント駆動複数銘柄・証券会社接続学習コストが高い
vectorbtベクトル演算数千通りの一括検証複雑な注文ロジック
自作完全な自由度バグが混入しやすい

Backtesting.pyの位置づけは明確で、「1銘柄・1戦略を素早く検証する」ことに特化しています。実際の自動売買までは面倒を見てくれませんが、そもそも検証段階でそこまで必要ありません。

使い分けの目安は次のとおりです。

  • 思いついたルールをとりあえず試したい → Backtesting.py
  • パラメータを数千通り総当たりしたいvectorbt
  • 複数銘柄をまとめて運用したいBacktrader

個人的に、最初に触るライブラリとしてこれを推す理由は「挫折しにくい」ことに尽きます。Backtraderは多機能ですが、最初のコードを動かすまでに覚えることが多く、そこで力尽きる人を何人も見てきました。

Backtesting.pyなら、20行程度で結果のレポートまで出ます。検証が習慣になってから、必要に応じて他のライブラリへ移ればよいという順序です。

逆に、次のような用途では最初から別のライブラリを選んでください。

  • 証券会社APIと接続して自動売買まで行いたい: Backtesting.pyは検証専用で、発注機能はない
  • ポートフォリオ全体をまとめて検証したい: 1銘柄ずつしか扱えない

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

環境を準備する

pip install backtesting yfinance pandas numpy bokeh

bokeh はグラフ描画に使われます。入れておかないと結果のプロットができないので、あわせてインストールしてください。

次に、日本株のデータを取得します。Backtesting.pyには列名の決まりがあり、ここを間違えるとエラーになります。

import yfinance as yf
import pandas as pd


def load_data(ticker, period="5y"):
    """Backtesting.pyが要求する形式に整えて返す"""
    df = yf.download(ticker, period=period, auto_adjust=True, progress=False)

    # yfinanceが列をMultiIndexで返す場合に備えて平坦化する
    if isinstance(df.columns, pd.MultiIndex):
        df.columns = df.columns.get_level_values(0)

    # 列名は Open / High / Low / Close / Volume(先頭大文字)が必須
    df = df[["Open", "High", "Low", "Close", "Volume"]].dropna()
    df.index = pd.to_datetime(df.index)
    return df


data = load_data("6501.T")     # 日立製作所
print(data.tail(3).round(1).to_string())
print(f"\n期間: {data.index[0].date()} 〜 {data.index[-1].date()}")
print(f"日数: {len(data):,}")

auto_adjust=True を必ず付けてください。株式分割を調整していないデータで検証すると、権利落ち日に見せかけの大暴落が発生し、成績がめちゃくちゃになります。

MultiIndexの平坦化を入れているのも実用上の理由です。yfinanceはバージョンや引数によって列構造が変わることがあり、そのままだとBacktesting.pyが列を認識できません。

SMAクロス戦略を実装する

題材は移動平均クロスです。短期線が長期線を上抜けたら買い、下抜けたら手仕舞う——最も基本的な順張り戦略です。

from backtesting import Backtest, Strategy
from backtesting.lib import crossover
import numpy as np


def SMA(values, n):
    """単純移動平均。Backtesting.pyのI()に渡すため関数にする"""
    return pd.Series(values).rolling(n).mean().values


class SmaCross(Strategy):
    n_short = 25
    n_long = 75

    def init(self):
        """指標を計算する(最初に1度だけ呼ばれる)"""
        close = self.data.Close
        self.sma_short = self.I(SMA, close, self.n_short)
        self.sma_long = self.I(SMA, close, self.n_long)

    def next(self):
        """1本ずつ進みながら呼ばれる。売買判断はここに書く"""
        if crossover(self.sma_short, self.sma_long):
            self.position.close()
            self.buy()
        elif crossover(self.sma_long, self.sma_short):
            self.position.close()

このライブラリの最大の利点がここにあります。next() は1本ずつ順に呼ばれるため、構造上、未来のデータにアクセスできません

自作のバックテストで最も多いバグは、その日の終値を見てその日に約定するルックアヘッドバイアスです。Backtesting.pyでは、next() の中で発注すると次の足の始値で約定する設計になっているため、このバグが起こりません。

自作コードでこの問題を防ぐ方法はルックアヘッドバイアスをPythonで排除する方法にまとめていますが、ライブラリに任せてしまうのが最も確実です。

実行して結果を見る

bt = Backtest(
    data,
    SmaCross,
    cash=3_000_000,        # 初期資金
    commission=0.001,      # 片道0.1%
    trade_on_close=False,  # 次の足の始値で約定(既定値)
    exclusive_orders=True, # 建玉は常に1つ
)

stats = bt.run()
print(stats)

commission を必ず設定してください。既定値は0なので、指定しないと手数料ゼロの世界で検証してしまいます。売買回数が多い戦略ほど、この差は致命的になります。

見るべき項目はどれか

出力される項目は30個近くありますが、実際に見るべきものは限られます。

項目意味目安
Return [%]累積リターンBuy & Hold と比べる
Buy & Hold Return [%]持ちっぱなしの成績これに勝てなければ意味がない
Max. Drawdown [%]最大の落ち込み−20%以内なら実用的
Sharpe Ratioリスク調整後リターン1.0以上で優秀
# Trades売買回数30未満なら評価不能
Win Rate [%]勝率70%超なら疑う
Profit Factor総利益÷総損失1.3以上が実用ライン

最も重要なのはBuy & Hold Return との比較です。手間をかけて売買した結果が、単に買って持っていた場合に負けているなら、その戦略に存在意義はありません。

def review(stats):
    """結果を要約して、実用性を判定する"""
    ret = float(stats["Return [%]"])
    bh = float(stats["Buy & Hold Return [%]"])
    trades = int(stats["# Trades"])
    sharpe = float(stats["Sharpe Ratio"])
    mdd = float(stats["Max. Drawdown [%]"])

    print(f"戦略リターン   : {ret:>8.1f} %")
    print(f"Buy & Hold     : {bh:>8.1f} %")
    print(f"差             : {ret - bh:>+8.1f} ポイント")
    print(f"売買回数       : {trades:>8d}")
    print(f"シャープ       : {sharpe:>8.2f}")
    print(f"最大DD         : {mdd:>8.1f} %")

    issues = []
    if trades < 30:
        issues.append(f"売買回数{trades}回では評価できません")
    if ret < bh:
        issues.append("Buy & Hold に負けています")
    if sharpe < 0.5:
        issues.append("リスクに見合っていません")

    print()
    for i in issues:
        print(f"  ⚠ {i}")
    if not issues:
        print("  ✓ 検討する価値はありそうです")


review(stats)

売買回数が30回に満たない場合、成績はほぼ運で決まります。何回必要かという議論はp値を知ってバックテストの見方が変わった話で詳しく扱っていますが、最低ラインとして30回、できれば100回は欲しいところです。

チャートで確認する

# インタラクティブなHTMLとして出力される
bt.plot(filename="sma_cross_result.html", open_browser=False)

数値だけでなくグラフを必ず目視してください。資産曲線を見ると、次のようなことが一目で分かります。

  • 特定の1回で稼いでいないか: 階段状に1段だけ大きく上がっていたら要注意
  • 直近も機能しているか: 前半だけ良くて後半が横ばいなら、優位性が消えている
  • 滑らかすぎないか: 落ち込みのない資産曲線はバグの可能性が高い

最適化とその落とし穴

Backtesting.pyには最適化機能が組み込まれており、パラメータの総当たりが数行で書けます。ただし、ここが最大の危険地帯です。

stats_opt, heatmap = bt.optimize(
    n_short=range(5, 31, 5),
    n_long=range(40, 121, 10),
    maximize="Sharpe Ratio",
    constraint=lambda p: p.n_short < p.n_long,
    return_heatmap=True,
)

print(f"最良パラメータ: {stats_opt._strategy}")
print(f"シャープ      : {float(stats_opt['Sharpe Ratio']):.2f}")

ここで出てくる数字は、そのまま信じてはいけません。過去データに最も合ったパラメータを選んでいるだけで、未来に通用する保証はまったくないからです。

ヒートマップで「地形」を確かめる

そこで役に立つのが return_heatmap=True です。最良の1点だけでなく、周辺の成績も一緒に見られます。

hm = heatmap.unstack()
print(hm.round(2).to_string())

values = heatmap.dropna()
best = float(values.max())
mean = float(values.mean())

print(f"\n最良 {best:.2f} / 平均 {mean:.2f} / 最悪 {float(values.min()):.2f}")
print(f"最良と平均の差: {best - mean:.2f}")

if best - mean > 1.0:
    print("⚠ 最良値が突出しています。カーブフィッティングの疑いあり")
else:
    print("✓ 全体的に安定しています")

判断基準はシンプルです。最良のマスの周りも同じくらい良い数字なら信頼でき、1マスだけ突出しているなら偶然です。

実務では、最良の1点ではなく「良い成績が固まっている領域の真ん中」を採用します。1位を狙うほど、偶然の当たりを掴む確率が上がるためです。

より厳密に過学習を避けたいなら、ウォークフォワード分析を併用してください。最適化した数字と、実際に使える数字の落差が分かります。

日本株特有の設定

単元株の制約を入れる

Backtesting.pyは既定で1株単位の売買を想定しています。日本株は通常100株単位なので、そのままでは現実と乖離します。

class SmaCrossJP(Strategy):
    n_short = 25
    n_long = 75
    unit = 100          # 売買単位

    def init(self):
        close = self.data.Close
        self.sma_short = self.I(SMA, close, self.n_short)
        self.sma_long = self.I(SMA, close, self.n_long)

    def next(self):
        if crossover(self.sma_short, self.sma_long):
            price = self.data.Close[-1]
            # 資金の95%で買える株数を、単元に丸める
            affordable = int(self.equity * 0.95 / price)
            size = (affordable // self.unit) * self.unit
            if size > 0:
                self.position.close()
                self.buy(size=size)
        elif crossover(self.sma_long, self.sma_short):
            self.position.close()


bt_jp = Backtest(data, SmaCrossJP, cash=3_000_000, commission=0.001)
print(bt_jp.run())

特に値がさ株では影響が大きくなります。株価が5,000円なら1単元50万円なので、資金300万円では6単元しか買えません。1株単位で計算していると、この制約が消えてしまいます。

手数料の設定を実態に合わせる

commission取引金額に対する割合で指定します。日本のネット証券は定額プランが多いため、割合に換算する必要があります。

# 定額手数料を、想定取引金額から割合に換算する
def to_rate(fee_yen, avg_trade_size):
    return fee_yen / avg_trade_size


for fee, size in [(0, 500_000), (275, 500_000), (535, 1_000_000)]:
    rate = to_rate(fee, size)
    print(f"手数料{fee:>4d}円 / 約定{size:>9,}円 → {rate:.5f} ({rate * 100:.3f}%)")

print("\n※スリッページも別途0.05〜0.1%程度を上乗せするのが安全です")

手数料が無料でもスリッページはゼロになりません。想定した価格で必ず約定するわけではないので、少なくとも0.05%程度は見込んでおくべきです。詳しくは手数料・スリッページをPythonで組み込む方法をご覧ください。

つまずきやすいポイント

列名エラーが出る

最頻出のエラーです。Backtesting.pyは Open High Low Close Volume という先頭大文字の列名を要求します。

# データを渡す前に検証する
def validate(df):
    required = ["Open", "High", "Low", "Close", "Volume"]
    missing = [c for c in required if c not in df.columns]
    if missing:
        print(f"✗ 列が足りません: {missing}")
        print(f"  現在の列: {list(df.columns)}")
        return False
    if df[required].isna().any().any():
        print("✗ 欠損値があります。dropna() してください")
        return False
    if not isinstance(df.index, pd.DatetimeIndex):
        print("✗ インデックスが日付型ではありません")
        return False
    print("✓ データ形式は問題ありません")
    return True


validate(data)

売買が1回も発生しない

原因はたいてい次の3つです。

  • 資金不足: 株価が高く、1株も買えない。cash を増やす
  • データが短い: 移動平均の期間より短く、指標が計算できていない
  • 条件が厳しすぎる: そもそもシグナルが発生していない

複数銘柄を扱いたい

Backtesting.pyは1銘柄ずつしか扱えません。複数銘柄を検証したい場合は、ループで1つずつ回して結果を集計します。

TICKERS = {"6501.T": "日立", "7203.T": "トヨタ",
           "6758.T": "ソニーG", "9432.T": "NTT"}

rows = []
for code, name in TICKERS.items():
    try:
        d = load_data(code)
        bt = Backtest(d, SmaCross, cash=3_000_000, commission=0.001)
        s = bt.run()
        rows.append({
            "銘柄": name,
            "戦略%": round(float(s["Return [%]"]), 1),
            "B&H%": round(float(s["Buy & Hold Return [%]"]), 1),
            "シャープ": round(float(s["Sharpe Ratio"]), 2),
            "売買": int(s["# Trades"]),
        })
    except Exception as e:
        print(f"{name}: {e}")

df_res = pd.DataFrame(rows)
df_res["勝敗"] = np.where(df_res["戦略%"] > df_res["B&H%"], "○", "×")
print(df_res.to_string(index=False))
print(f"\nBuy&Holdに勝った銘柄: "
      f"{(df_res['勝敗'] == '○').sum()} / {len(df_res)}")

この集計をやってみると、多くの銘柄でBuy & Holdに負けるという現実に直面します。移動平均クロスのような単純な戦略では、それが普通の結果です。

本格的に複数銘柄を同時運用する検証をしたいなら、Backtrader を使うほうが適しています。BacktraderでRSI+MACDを検証する方法で扱っています。

まとめ

Backtesting.pyを使ったSMAクロス戦略の検証について、実装から結果の読み方までまとめました。

  • Backtesting.pyは1銘柄・1戦略を素早く検証するのに最適
  • 列名は先頭大文字が必須。auto_adjust=True も忘れない
  • next() は1本ずつ進むため、ルックアヘッドが構造的に起きない
  • commission の既定値は0。指定しないと手数料ゼロで検証してしまう
  • 最も重要な指標はBuy & Hold Return との比較。売買回数30未満は評価不能
  • 最適化はヒートマップで地形を確認。1マスだけ突出なら採用しない
  • 日本株では単元株の制約を入れないと現実と乖離する

このライブラリの一番の価値は、検証を「面倒だからやらない」から「とりあえず試す」に変えてくれることだと思います。20行程度で結果が出るので、思いついたルールをその場で確かめる習慣がつきます。

使い始めるにあたって、1つだけ心構えを書いておきます。最初に試した戦略が良い成績を出すことは、まずありません。移動平均クロスもRSIの逆張りも、何十年も前から知られている手法です。それが今も簡単に通用するなら、とっくに誰かが刈り取っています。

それでも検証する意味はあります。「効かない」と自分の手で確認したものは、二度と迷わなくなるからです。ネットで見かけた必勝法に心を揺らす回数が、確実に減ります。

まずは本記事のコードをそのまま動かして、自分の気になる銘柄で試してみてください。Buy & Hold に勝てないという結果が出ても、それは失敗ではなく重要な発見です。無駄な売買を減らすきっかけになります。

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