Pythonで作る最小構成バックテストの実装と読み方

自動化・運用

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

「このルールで売買していたら、過去1年間でいくら儲かっていたのか?」——この問いに定量的に答える手法がバックテストです。裁量トレードであれシステムトレードであれ、売買ロジックを過去データで検証せずに実運用するのは、地図を持たずに航海に出るようなものです。

📘 外部参考Backtesting.py(公式)Backtrader 公式

しかし、バックテスト用のフレームワーク(Backtrader、Ziplineなど)は多機能ゆえに学習コストが高く、初心者が最初の一歩を踏み出しにくいという課題があります。

この記事では、外部フレームワークを一切使わず、yfinanceとpandasだけで構築できる最小構成のバックテストコードを提供します。ゴールデンクロス戦略を題材に、シグナル生成から損益計算、パフォーマンス指標の算出までを一気通貫で実装します。

📘 外部参考yfinance 公式GitHub

📘 外部参考株式等の譲渡益課税(国税庁・公式)

バックテストの基本概念

コードに入る前に、バックテストの仕組みと注意すべきバイアスについて整理します。

バックテストとは何か

バックテストとは、過去の価格データに対して売買ルールを適用し、仮にそのルールで運用していた場合の損益をシミュレーションする手法です。

基本的な処理の流れは以下のとおりです。

  1. 過去の株価データを取得する
  2. 売買ルール(ストラテジー)に基づいて「買い」「売り」のシグナルを生成する
  3. シグナルに従って仮想的にポジションを取る
  4. 期間全体の損益・勝率・リスク指標を算出する

バックテストで注意すべきバイアス

バックテストには「過去データだから当然うまくいく」という罠があります。以下のバイアスを常に意識してください。

バイアス名 内容 対策
ルックアヘッドバイアス 未来のデータを使ってシグナルを生成してしまう シグナル生成は必ず「その時点までのデータ」のみで行う
サバイバーシップバイアス 上場廃止銘柄を除外した生存者だけで検証する 可能な限り廃止銘柄も含めたデータを使用する
オーバーフィッティング 過去データに過剰適合したパラメータを採用する パラメータ数を最小限にし、複数期間で検証する
約定価格の乖離 バックテストでは始値で約定できる前提だが現実は異なる スリッページ(滑り)を考慮したコスト設定を入れる

📘 外部参考Slippage(Investopedia)

バックテストの結果が良好でも、実運用で同じ成績が出る保証はありません。過信せず、あくまで「戦略の妥当性を確認するツール」として位置づけてください。

今回実装する戦略:ゴールデンクロス/デッドクロス

今回のバックテストでは、最も基本的なテクニカル指標である移動平均線のクロス戦略を採用します。

  • ゴールデンクロス(買いシグナル): 短期移動平均線が長期移動平均線を下から上に突き抜けた
  • デッドクロス(売りシグナル): 短期移動平均線が長期移動平均線を上から下に突き抜けた

シンプルなルールですが、バックテストの仕組みを学ぶ題材として最適です。

データ取得と前処理

まずはバックテストに必要な株価データを取得し、移動平均線を計算します。

【コピペOK】株価データ取得と移動平均線の算出コード

以下のコードは、yfinanceで過去3年分の日足データを取得し、短期(25日)と長期(75日)の移動平均線を算出します。


import yfinance as yf
import pandas as pd

# ==============================
# 設定エリア
# ==============================
SYMBOL = "7203.T"        # 銘柄コード(トヨタ自動車)
PERIOD = "3y"            # 取得期間(3年分)
SHORT_WINDOW = 25        # 短期移動平均の期間
LONG_WINDOW = 75         # 長期移動平均の期間

# ==============================
# データ取得
# ==============================
def fetch_data(symbol: str, period: str) -> pd.DataFrame:
    print(f"--- {symbol} の株価データを取得中 ---")
    ticker = yf.Ticker(symbol)
    df = ticker.history(period=period)

    if df.empty:
        raise ValueError("データの取得に失敗しました。銘柄コードとネットワーク接続を確認してください。")

    df = df[["Close"]].copy()
    df.columns = ["close"]
    print(f"取得件数: {len(df)}行")
    return df

# ==============================
# 移動平均線の算出
# ==============================
def add_moving_averages(df: pd.DataFrame) -> pd.DataFrame:
    df["sma_short"] = df["close"].rolling(window=SHORT_WINDOW).mean()
    df["sma_long"] = df["close"].rolling(window=LONG_WINDOW).mean()
    df.dropna(inplace=True)
    return df

if __name__ == "__main__":
    data = fetch_data(SYMBOL, PERIOD)
    data = add_moving_averages(data)
    print(data.tail(10))

出力データの構造

上記コードを実行すると、以下のようなDataFrameが生成されます。

日付 close sma_short sma_long
2025-12-01 2850.0 2780.5 2720.3
2025-12-02 2870.0 2785.2 2722.1
  • close: 終値
  • sma_short: 25日移動平均線
  • sma_long: 75日移動平均線

この3列をもとに、次のステップでシグナルを生成します。

シグナル生成とポジション管理

移動平均線が計算できたら、クロスのタイミングを検出して売買シグナルを生成します。

クロス検出のロジック

ゴールデンクロスとデッドクロスは、短期線と長期線の大小関係が前日と当日で逆転したかどうかで判定します。


前日: 短期 < 長期 → 当日: 短期 > 長期 = ゴールデンクロス(買い)
前日: 短期 > 長期 → 当日: 短期 < 長期 = デッドクロス(売り)

【コピペOK】シグナル生成コード

以下の関数をコードに追加してください。


# ==============================
# シグナル生成
# ==============================
def generate_signals(df: pd.DataFrame) -> pd.DataFrame:
    df["signal"] = 0

    # 短期線が長期線を上回っている日を1、下回っている日を0
    df["position_flag"] = (df["sma_short"] > df["sma_long"]).astype(int)

    # フラグの変化を検出(0→1: 買い、1→0: 売り)
    df["signal"] = df["position_flag"].diff()

    # signal =  1 → ゴールデンクロス(買い)
    # signal = -1 → デッドクロス(売り)
    # signal =  0 → シグナルなし

    buy_count = (df["signal"] == 1).sum()
    sell_count = (df["signal"] == -1).sum()
    print(f"買いシグナル: {buy_count}回 / 売りシグナル: {sell_count}回")

    return df

ポジション管理の考え方

今回の簡易バックテストでは、以下のシンプルなルールでポジションを管理します。

  • ゴールデンクロス発生日の翌日の始値で買い(1単元)
  • デッドクロス発生日の翌日の始値で売り(全ポジション決済)
  • 同時に複数ポジションは持たない(常に0か1)
  • 空売りは行わない

「翌日の始値で約定」とすることで、ルックアヘッドバイアスを回避しています。シグナルが出た当日の終値で約定する設計にすると、「その日の終値を知ってから売買を決定している」ことになり、現実には再現できません。

【コピペOK】完全版バックテストコード

ここまでの要素をすべて統合した、コピペで動く完全版バックテストコードを以下に示します。


import yfinance as yf
import pandas as pd

# ==============================
# 設定エリア
# ==============================
SYMBOL = "7203.T"        # 銘柄コード
PERIOD = "3y"            # 取得期間
SHORT_WINDOW = 25        # 短期移動平均
LONG_WINDOW = 75         # 長期移動平均
INITIAL_CAPITAL = 1_000_000  # 初期資金(円)

# ==============================
# データ取得
# ==============================
def fetch_data(symbol: str, period: str) -> pd.DataFrame:
    print(f"--- {symbol} の株価データを取得中 ---")
    ticker = yf.Ticker(symbol)
    df = ticker.history(period=period)

    if df.empty:
        raise ValueError("データの取得に失敗しました。")

    df = df[["Open", "Close"]].copy()
    df.columns = ["open", "close"]
    print(f"取得件数: {len(df)}行")
    return df

# ==============================
# 移動平均線の算出
# ==============================
def add_moving_averages(df: pd.DataFrame) -> pd.DataFrame:
    df["sma_short"] = df["close"].rolling(window=SHORT_WINDOW).mean()
    df["sma_long"] = df["close"].rolling(window=LONG_WINDOW).mean()
    df.dropna(inplace=True)
    return df

# ==============================
# シグナル生成
# ==============================
def generate_signals(df: pd.DataFrame) -> pd.DataFrame:
    df["position_flag"] = (df["sma_short"] > df["sma_long"]).astype(int)
    df["signal"] = df["position_flag"].diff()
    return df

# ==============================
# バックテスト実行
# ==============================
def run_backtest(df: pd.DataFrame) -> pd.DataFrame:
    capital = INITIAL_CAPITAL
    position = 0          # 保有株数
    entry_price = 0.0     # 購入単価
    trades = []           # 取引記録

    for i in range(1, len(df)):
        row = df.iloc[i]
        prev_signal = df.iloc[i - 1]["signal"]

        # 前日に買いシグナル → 当日始値で買い
        if prev_signal == 1 and position == 0:
            shares = int(capital // row["open"])
            if shares > 0:
                position = shares
                entry_price = row["open"]
                capital -= shares * entry_price
                trades.append({
                    "date": df.index[i],
                    "action": "BUY",
                    "price": entry_price,
                    "shares": shares,
                    "capital": capital
                })

        # 前日に売りシグナル → 当日始値で売り
        elif prev_signal == -1 and position > 0:
            exit_price = row["open"]
            profit = (exit_price - entry_price) * position
            capital += position * exit_price
            trades.append({
                "date": df.index[i],
                "action": "SELL",
                "price": exit_price,
                "shares": position,
                "profit": profit,
                "capital": capital
            })
            position = 0

    # 期末に未決済ポジションがあれば最終終値で評価
    if position > 0:
        last_close = df.iloc[-1]["close"]
        unrealized = (last_close - entry_price) * position
        capital += position * last_close
        trades.append({
            "date": df.index[-1],
            "action": "CLOSE(未決済)",
            "price": last_close,
            "shares": position,
            "profit": unrealized,
            "capital": capital
        })

    trades_df = pd.DataFrame(trades)
    return trades_df, capital

# ==============================
# パフォーマンス指標の算出
# ==============================
def calc_performance(trades_df: pd.DataFrame, final_capital: float):
    print("n========== バックテスト結果 ==========")
    print(f"初期資金       : {INITIAL_CAPITAL:>12,.0f} 円")
    print(f"最終資金       : {final_capital:>12,.0f} 円")
    print(f"損益           : {final_capital - INITIAL_CAPITAL:>12,.0f} 円")
    print(f"リターン       : {((final_capital / INITIAL_CAPITAL) - 1) * 100:>11.2f} %")

    sell_trades = trades_df[trades_df["action"].isin(["SELL", "CLOSE(未決済)"])]

    if len(sell_trades) == 0:
        print("決済取引がありませんでした。")
        return

    total_trades = len(sell_trades)
    win_trades = len(sell_trades[sell_trades["profit"] > 0])
    lose_trades = len(sell_trades[sell_trades["profit"] <= 0])
    win_rate = (win_trades / total_trades) * 100

    avg_profit = sell_trades[sell_trades["profit"] > 0]["profit"].mean() if win_trades > 0 else 0
    avg_loss = sell_trades[sell_trades["profit"] <= 0]["profit"].mean() if lose_trades > 0 else 0

    print(f"総取引回数     : {total_trades:>12} 回")
    print(f"勝ち           : {win_trades:>12} 回")
    print(f"負け           : {lose_trades:>12} 回")
    print(f"勝率           : {win_rate:>11.1f} %")
    print(f"平均利益(勝ち) : {avg_profit:>12,.0f} 円")
    print(f"平均損失(負け) : {avg_loss:>12,.0f} 円")

    if avg_loss != 0:
        pf = abs(avg_profit * win_trades) / abs(avg_loss * lose_trades)
        print(f"プロフィットF  : {pf:>11.2f}")

    print("======================================")

# ==============================
# メイン処理
# ==============================
def main():
    df = fetch_data(SYMBOL, PERIOD)
    df = add_moving_averages(df)
    df = generate_signals(df)

    trades_df, final_capital = run_backtest(df)

    if not trades_df.empty:
        print("n--- 取引履歴 ---")
        print(trades_df.to_string(index=False))

    calc_performance(trades_df, final_capital)

if __name__ == "__main__":
    main()

出力結果の読み方

上記コードを実行すると、以下のような出力が得られます(数値は実行時期により異なります)。


========== バックテスト結果 ==========
初期資金       :    1,000,000 円
最終資金       :    1,085,200 円
損益           :       85,200 円
リターン       :        8.52 %
総取引回数     :            6 回
勝ち           :            4 回
負け           :            2 回
勝率           :        66.7 %
平均利益(勝ち) :       35,800 円
平均損失(負け) :      -21,500 円
プロフィットF  :        3.33
======================================

各指標の意味は以下のとおりです。

  • リターン: 初期資金に対する最終的な増減率
  • 勝率: 決済した取引のうち、利益が出た割合
  • プロフィットファクター(PF): 総利益 ÷ 総損失。1.0以上で利益が損失を上回っている

📘 外部参考Profit Factor(Investopedia)

カスタマイズのポイント

完全版コードをベースに、以下の要素を変更することで様々な検証が可能です。

パラメータの変更

設定エリアの値を変更するだけで、異なる条件のバックテストが実行できます。

変更対象 設定箇所
銘柄 SYMBOL "9984.T"(ソフトバンクグループ)
期間 PERIOD "5y"(5年分)
短期移動平均 SHORT_WINDOW 5(5日線)
長期移動平均 LONG_WINDOW 20(20日線)
初期資金 INITIAL_CAPITAL 500_000(50万円)

戦略ロジックの差し替え

generate_signals 関数の中身を差し替えれば、移動平均クロス以外の戦略も検証できます。例えば以下のような拡張が考えられます。

  • RSI(相対力指数)の閾値による逆張り戦略
  • ボリンジャーバンドのブレイクアウト戦略
  • 複数指標の組み合わせ(AND条件・OR条件)

パラメータを変えるたびに結果が改善するからといって、過去データに最適化しすぎないことがバックテストの最大の注意点です。検証期間を「学習期間」と「テスト期間」に分割するウォークフォワード分析を取り入れると、過学習のリスクを軽減できます。

よくあるエラーと対処法

取引が一度も発生しない

移動平均の期間設定に対してデータ取得期間が短すぎると、クロスが一度も発生しないことがあります。PERIOD"3y" 以上に設定し、十分なデータ量を確保してください。

また、SHORT_WINDOWLONG_WINDOW の値が近すぎると、クロスが頻発しすぎてノイズになる場合もあります。一般的には短期25日・長期75日、または短期5日・長期20日の組み合わせがよく使われます。

「int(capital // row[“open”])」で0株になる

株価に対して初期資金が少なすぎる場合、購入可能株数が0になります。INITIAL_CAPITAL を増やすか、株価が低い銘柄で検証してください。

日本株は100株単位での売買が基本ですが、このコードでは学習目的のため1株単位で計算しています。100株単位にしたい場合は以下のように修正します。


shares = int(capital // (row["open"] * 100)) * 100

DataFrameの警告「SettingWithCopyWarning」が出る

pandasのバージョンによっては、DataFrame操作時に警告が表示される場合があります。動作には影響しませんが、気になる場合は .copy() メソッドの利用を徹底することで解消できます。

まとめ

バックテストは売買戦略の妥当性を数値で検証するための必須プロセスです。この記事のポイントを整理します。

  • バックテストは「過去データに売買ルールを適用して仮想損益を計算する」シミュレーション手法
  • ルックアヘッドバイアスを回避するため、シグナル発生日の翌日始値で約定する設計が重要
  • yfinanceとpandasだけで、フレームワーク不要の最小構成バックテストが構築できる
  • 勝率・リターン・プロフィットファクターの3指標で戦略の良し悪しを定量評価する
  • パラメータの過剰最適化(オーバーフィッティング)に注意する

今回構築した最小構成のコードは、より高度なバックテストフレームワーク(Backtrader等)に移行するための土台となります。まずはこのコードで「バックテストの考え方」を体得し、段階的に機能を拡張していくことを推奨します。

🔗 関連記事

Pythonで株価バックテストを実装する方法【初心者向け・移動平均クロス戦略】

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