Backtesting.pyの使い方|移動平均クロス検証とsell()の罠

Python実装・コード

「この投資戦略、本当に使えるのか」を確かめるのがバックテストです。自分でpandasを書いてもできますが、約定処理や手数料の扱いを毎回書くのは面倒です。Backtesting.pyを使えば、init()next()の2つを書くだけで済みます。

ただ、入門記事のとおりに書くと意図しない戦略を検証することになります。原因はself.sell()です。これは「持っているものを売る」ではなく、新しく空売りを建てる命令です。「デッドクロスで売り」のつもりが、実際にはドテン売りの戦略を検証していた、というのがよくある事故です。

この記事では、インストールから移動平均クロスの検証までを書きつつ、ポジションの閉じ方、yfinanceの列構造、約定タイミングの設定という3つの実務的な落とし穴を潰します。

📘 外部参考Backtesting.py 公式ドキュメントyfinance(GitHub)

Backtesting.pyの特徴

  • コード量が少ない。戦略クラスにinit()next()を書くだけ
  • インタラクティブなチャート。bt.plot()でブラウザに売買履歴と資産推移が出る
  • パラメータ最適化が組み込み。グリッドサーチとヒートマップが標準
  • 指標がまとめて出る。シャープレシオ、最大ドローダウン、勝率、取引回数

逆に苦手なのは、複数銘柄を同時に扱うポートフォリオ運用です。1銘柄の戦略を素早く検証する用途に向いています。

インストールとデータ準備

pip install backtesting yfinance pandas

pandas_taを入れる記事もありますが、依存が重くバージョン相性の問題も起きやすいので、指標は自前の関数で十分です。self.I()には普通の関数を渡せます。

データ側の注意点はこれです。yfinanceの戻り値はMultiIndexになることがあり、そのままBacktesting.pyに渡すと落ちます。

import numpy as np
import pandas as pd
import yfinance as yf

def load_for_backtest(ticker: str, start: str = "2019-01-01") -> pd.DataFrame:
    df = yf.download(ticker, start=start, auto_adjust=True, progress=False)
    if df.empty:
        raise RuntimeError(f"{ticker}: データなし")
    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)
    # Backtesting.py は列名がこの5つであることを前提にしている
    df.columns = ["Open", "High", "Low", "Close", "Volume"]
    return df.sort_index()

data = load_for_backtest("7203.T")
print(data.tail(3), len(data))

auto_adjust=Trueも重要です。未調整の価格だと、株式分割の日に価格が半分になり、そこでデッドクロスが発生します。存在しない暴落で損切りする戦略を検証することになります。

移動平均クロス戦略を書く

from backtesting import Backtest, Strategy
from backtesting.lib import crossover

def SMA(values, n: int):
    """self.I に渡すための単純移動平均。ndarrayでもSeriesでも動くようにする。"""
    return pd.Series(values).rolling(n).mean().to_numpy()

class SmaCross(Strategy):
    n1 = 20   # 短期
    n2 = 50   # 長期

    def init(self):
        close = self.data.Close
        self.sma1 = self.I(SMA, close, self.n1)
        self.sma2 = self.I(SMA, close, self.n2)

    def next(self):
        if crossover(self.sma1, self.sma2):
            self.buy()
        elif crossover(self.sma2, self.sma1):
            self.position.close()      # 空売りではなく、手仕舞い

self.position.close()が今回いちばんの要点です。ここをself.sell()にすると、デッドクロスのたびに空売りポジションを建てます。ロングオンリーのつもりで書いた戦略が、実際にはドテン売買になります。

書き方実際の動作使う場面
self.buy()買い注文を出すロングを建てる
self.sell()空売り注文を出すショートを建てたいとき
self.position.close()現在のポジションを閉じる手仕舞い
exclusive_orders=True新規注文時に既存を自動で閉じる常にドテンさせたいとき

成績を比べてみると、この違いの大きさが分かります。

class SmaCrossShort(SmaCross):
    def next(self):
        if crossover(self.sma1, self.sma2):
            self.buy()
        elif crossover(self.sma2, self.sma1):
            self.sell()           # ドテン売り版

def run(strategy, data, **kw) -> pd.Series:
    bt = Backtest(data, strategy, cash=1_000_000, commission=0.001, **kw)
    return bt.run()

for name, strat in [("手仕舞いのみ", SmaCross), ("ドテン売り", SmaCrossShort)]:
    s = run(strat, data)
    print(f"{name:8s} Return={s['Return [%]']:7.2f}  "
          f"MDD={s['Max. Drawdown [%]']:7.2f}  "
          f"Trades={s['# Trades']:3d}  Sharpe={s['Sharpe Ratio']:.2f}")

下降相場を含む期間では、成績が大きく変わります。どちらが良いかではなく、自分が検証したかったのはどちらかを意識して書くのが大事です。

約定タイミングと手数料

Backtest()の引数で挙動が変わります。既定値のまま使うと、実際の運用と前提がずれます。

bt = Backtest(
    data,
    SmaCross,
    cash=1_000_000,
    commission=0.001,        # 片道0.1%。手数料+スリッページの合算として設定
    trade_on_close=False,    # False=翌足の始値で約定(現実的)
    exclusive_orders=False,
    finalize_trades=True,    # 最終日に残ったポジションを清算して集計に含める
)
stats = bt.run()
print(stats)
  • trade_on_close=False:シグナルが出た足の終値ではなく、翌足の始値で約定します。終値でシグナルを判定して同じ終値で約定するのは現実にはできないので、こちらが基本です。
  • commission:手数料無料の証券会社が増えましたが、スリッページはゼロになりません。0.05〜0.2%程度を入れておくのが実務的です。
  • finalize_trades=True:これを指定しないと、最終日に持ち越したポジションが取引集計から漏れます。含み損を抱えたまま終わった戦略が、実際より良く見えます。

手数料への感応度を見る

def commission_sweep(data, strategy, fees=(0.0, 0.0005, 0.001, 0.002, 0.005)):
    rows = []
    for f in fees:
        s = Backtest(data, strategy, cash=1_000_000, commission=f,
                     finalize_trades=True).run()
        rows.append({
            "commission": f,
            "return_pct": round(float(s["Return [%]"]), 2),
            "sharpe": round(float(s["Sharpe Ratio"]), 3),
            "trades": int(s["# Trades"]),
        })
    return pd.DataFrame(rows).set_index("commission")

print(commission_sweep(data, SmaCross))

手数料を上げたときに成績が急落するなら、その戦略は取引回数に依存しています。tradesが多いほど、コストの前提が結論を左右します。

結果の読み方

項目意味見るポイント
Return [%]総リターンBuy&Holdと比べる
Buy & Hold Return [%]持ちっぱなしの成績これに勝てないなら意味がない
Max. Drawdown [%]最大ドローダウン-20%超なら握れるか要検討
Sharpe Ratioリスク調整後リターン1.0以上が目安
Exposure Time [%]市場に居た時間の割合短いのに同等なら効率は良い
# Trades取引回数少なすぎると統計的に無意味

いちばん見落とされるのが# Tradesです。取引が10回程度なら、どんな数字が出ても偶然の範囲です。勝率70%と言っても、10回中7回。コインを10回投げて7回表が出るのは珍しくありません。最低でも数十回、できれば100回以上ほしいところです。

Exposure Timeも重要です。この戦略は下落局面でキャッシュに逃げるので、市場に居る時間がBuy&Holdより短くなります。リターンが半分でも滞在時間が半分なら、単位リスクあたりでは互角です。

最適化とオーバーフィット

bt = Backtest(data, SmaCross, cash=1_000_000, commission=0.001,
              finalize_trades=True)

stats, heatmap = bt.optimize(
    n1=range(5, 31, 5),
    n2=range(30, 101, 10),
    constraint=lambda p: p.n1 < p.n2,    # 短期<長期を保証する
    maximize="Sharpe Ratio",
    return_heatmap=True,
)
print(stats["_strategy"])
print(heatmap.sort_values(ascending=False).head(10))

constraintを入れないと、短期>長期のありえない組み合わせも試されます。

そして、最適値をそのまま採用してはいけません。周辺のパラメータでも成績が良いかを確認します。

def robustness(heatmap: pd.Series, span_n1: int = 10, span_n2: int = 20) -> None:
    best = heatmap.idxmax()
    n1, n2 = best
    near = heatmap[
        (heatmap.index.get_level_values("n1").to_series().between(n1 - span_n1, n1 + span_n1).values) &
        (heatmap.index.get_level_values("n2").to_series().between(n2 - span_n2, n2 + span_n2).values)
    ]
    print(f"best  n1={n1} n2={n2}  sharpe={heatmap.max():.3f}")
    print(f"周辺{len(near)}点  平均={near.mean():.3f}  最小={near.min():.3f}")

robustness(heatmap.dropna())

最良点が2.0で周辺平均が0.1なら、それは偶然掘り当てた1点です。実運用で再現しません。点ではなく面で判断するのが、最適化と付き合う唯一のやり方です。

期間を分けて確認する

def split_check(data, strategy, ratio: float = 0.6, **kw):
    k = int(len(data) * ratio)
    rows = []
    for name, sub in [("前半", data.iloc[:k]), ("後半", data.iloc[k:])]:
        s = Backtest(sub, strategy, cash=1_000_000, commission=0.001,
                     finalize_trades=True).run(**kw)
        rows.append({
            "period": name,
            "return_pct": round(float(s["Return [%]"]), 2),
            "sharpe": round(float(s["Sharpe Ratio"]), 3),
            "trades": int(s["# Trades"]),
        })
    return pd.DataFrame(rows).set_index("period")

print(split_check(data, SmaCross))

前半で選んだパラメータが後半でも通用するか。ここで崩れるなら、最適化した意味はありません。

チャートで確認する

bt.plot(open_browser=True, filename="backtest.html")

ローソク足に移動平均線、売買シグナル、資産推移が重なって表示されます。数字だけでは気づけないことが見えます。僕の場合、「シグナルが出ていない長い空白期間」に気づいて、取引回数の少なさを認識できました。

まとめ

Backtesting.pyを使えば、数十行で戦略を検証できます。ただし、既定値のまま使うと前提がずれるので、次の4点を明示してください。

手仕舞いはself.position.close()で行う(self.sell()は空売り)。trade_on_close=Falseで翌足の始値約定にする。finalize_trades=Trueで持ち越しポジションを集計に含める。commissionにスリッページ込みの値を入れて、感応度を確認する。

そして結果を見るときは、# TradesExposure Timeを先に確認します。取引回数が少なければ、他の数字はすべて偶然です。仕組みを自分で書いて理解したい場合は、pandasで書くバックテストの記事も参考にしてください。

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