【Python実装】株アルゴリズムのバックテスト完全手順!yfinanceで自作戦略を検証するロードマップ

Python実装・コード

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

Pythonで株式アルゴリズムを開発した投資家の多くが、最初の大きな失敗を経験します。それは「バックテストで素晴らしい成績が出たのに、実運用では全く利益が出ない」という現象です。その理由は、過去データへの過度な最適化(過学習)、手数料やスリッページの見落とし、生存者バイアスなど、多くの落とし穴が存在するからです。

バックテストは、アルゴリズム取引の開発プロセスの中で最も重要なステップです。本来の目的は「戦略が本当に機能するのか」を厳密に検証することですが、多くの初心者は結果を過信してしまいます。

この記事では、バックテストの正しい実装方法から、結果の解釈、そして改善サイクルまでをステップバイステップで解説します。実装例はすべてPythonとyfinanceで動作し、コピペで実行可能です。さらに、バックテスト結果をどのように判断し、実運用へ移行するかという「実務的な意思決定プロセス」も網羅しています。

バックテストの正しい目的と過学習の落とし穴

多くの初心者がバックテストを誤解しています。その違いを最初に理解することが、実運用での成功につながります。

バックテストは「最適な戦略を探す」ではなく「戦略が機能するか検証する」

バックテストの目的は、以下の2つに分かれます。

  • 開発段階: 複数の仮説の中から、最も有望なものを選ぶ
  • 検証段階: 選んだ戦略が、本当に実運用で利益を出すか確認する

しかし、実際には多くの投資家が以下のような誤った使い方をしています。

  • 過去データに完璧に適合したパラメータを探す(例:移動平均線の期間を10日から100日まで全て試す)
  • 結果が高い順にパラメータを並べて、最高成績の設定を選ぶ
  • その設定で実運用を開始する

このアプローチは「過学習」を招きます。過去データに過度に最適化されたパラメータは、未来の市場では通用しなくなるためです。

過学習の実例:シミュレーションと現実の乖離

具体的に、以下のシナリオを考えてみます。

仮設定: 移動平均線(期間N)のクロスシグナルで売買する

  • バックテストで期間N=25を試す → 年間利益率 22%
  • バックテストで期間N=26を試す → 年間利益率 18%
  • バックテストで期間N=27を試す → 年間利益率 15%

この結果を見て、「期間25が最適だ」と判断して実運用を開始したら、実際の利益率は3~5%にとどまった、という現象は珍しくありません。

なぜこのようなことが起こるのか。理由は、「2018~2023年の過去データに対して、期間25という設定が最も相性よく機能したに過ぎない」ということです。未来のデータ(2024年以降)は、過去と同じ市場環境ではないため、同じパラメータが機能する保証はありません。

正しいバックテストの3段階プロセス

過学習を避け、実運用で機能するアルゴリズムを開発するには、以下の3段階に分けて検証する必要があります。

ステップ目的手法期間
1. 開発テスト複数の仮説から有望なものを選ぶ2018~2022年のデータを使用、複数パラメータを試験5年
2. 検証テスト開発で選んだ戦略が独立したデータで機能するか確認2023年のデータを使用(開発に使わなかったデータ)1年
3. 前向きテスト(フォワードテスト)実際の市場での機能を小額資金で確認2024年以降のリアルタイムデータ3~6ヶ月

この3段階を必ず実行してから、本運用に移行することが鉄則です。

【コピペOK】yfinanceを使ったバックテストの基本実装

では、実際にバックテストを実装してみます。まずは、単純な移動平均線クロス戦略を例に、基本的なバックテストエンジンを構築します。

import numpy as np
import pandas as pd
import yfinance as yf
from datetime import datetime, timedelta
import matplotlib.pyplot as plt

# ==============================
# 設定エリア
# ==============================
SYMBOL = "7203.T"              # トヨタ自動車
START_DATE = "2019-01-01"
END_DATE = "2023-12-31"
MA_SHORT = 20                  # 短期移動平均線の日数
MA_LONG = 50                   # 長期移動平均線の日数
INITIAL_CASH = 1000000         # 初期資金100万円
COMMISSION = 0.001             # 手数料0.1%(往復で0.2%)

# ==============================
# データ取得
# ==============================
def fetch_data(symbol, start_date, end_date):
    """
    yfinanceで株価データを取得

    Args:
        symbol (str): 銘柄コード
        start_date (str): 開始日付(YYYY-MM-DD)
        end_date (str): 終了日付(YYYY-MM-DD)

    Returns:
        pd.DataFrame: OHLCV データ
    """
    print(f"[{datetime.now()}] {symbol} のデータ取得中({start_date}~{end_date})...")

    try:
        df = yf.download(symbol, start=start_date, end=end_date, progress=False)

        if df.empty:
            raise ValueError("データが取得できませんでした")

        print(f"[{datetime.now()}] 取得完了: {len(df)}営業日分のデータ")
        return df

    except Exception as e:
        print(f"エラー: {e}")
        return None

# ==============================
# テクニカル指標の計算
# ==============================
def calculate_moving_averages(df, short_period, long_period):
    """
    短期・長期移動平均線を計算

    Args:
        df (pd.DataFrame): 価格データ
        short_period (int): 短期移動平均の期間
        long_period (int): 長期移動平均の期間

    Returns:
        pd.DataFrame: 移動平均線が追加されたDataFrame
    """
    df['MA_SHORT'] = df['Close'].rolling(window=short_period).mean()
    df['MA_LONG'] = df['Close'].rolling(window=long_period).mean()

    return df

# ==============================
# 売買シグナルの生成
# ==============================
def generate_signals(df):
    """
    ゴールデンクロス・デッドクロスのシグナルを生成

    Returns:
        pd.Series: 1=買いシグナル, -1=売りシグナル, 0=シグナルなし
    """
    signals = pd.Series(0, index=df.index)

    for i in range(1, len(df)):
        # 前営業日と当営業日のMAsを比較
        prev_short = df['MA_SHORT'].iloc[i-1]
        prev_long = df['MA_LONG'].iloc[i-1]
        curr_short = df['MA_SHORT'].iloc[i]
        curr_long = df['MA_LONG'].iloc[i]

        # データ不足の場合はスキップ
        if pd.isna(prev_short) or pd.isna(curr_short):
            continue

        # ゴールデンクロス:短期MA > 長期MA、かつ前日は短期MA < 長期MA
        if prev_short < prev_long and curr_short > curr_long:
            signals.iloc[i] = 1

        # デッドクロス:短期MA < 長期MA、かつ前日は短期MA > 長期MA
        elif prev_short > prev_long and curr_short < curr_long:
            signals.iloc[i] = -1

    return signals

# ==============================
# バックテスト実行エンジン
# ==============================
def backtest(df, signals, initial_cash, commission):
    """
    バックテストを実行し、取引履歴と成績を計算

    Args:
        df (pd.DataFrame): 価格データ
        signals (pd.Series): 売買シグナル
        initial_cash (float): 初期資金
        commission (float): 手数料率

    Returns:
        dict: 取引履歴、成績等を含む辞書
    """
    cash = initial_cash
    position = 0           # 保有株数
    entry_price = 0        # 買値
    trades = []            # 取引履歴
    portfolio_values = []  # 資産推移

    for i in range(len(df)):
        current_price = df['Close'].iloc[i]
        signal = signals.iloc[i]

        # 買いシグナル
        if signal == 1 and position == 0:
            # 買える株数を計算
            shares = int(cash / current_price * (1 - commission))

            if shares > 0:
                cost = shares * current_price * (1 + commission)
                cash -= cost
                position = shares
                entry_price = current_price

                trades.append({
                    'date': df.index[i],
                    'type': 'BUY',
                    'price': current_price,
                    'shares': shares,
                    'cash_remaining': cash,
                })

        # 売りシグナル
        elif signal == -1 and position > 0:
            revenue = position * current_price * (1 - commission)
            cash += revenue

            profit = revenue - (entry_price * position)
            profit_rate = (current_price - entry_price) / entry_price

            trades.append({
                'date': df.index[i],
                'type': 'SELL',
                'price': current_price,
                'shares': position,
                'profit': profit,
                'profit_rate': profit_rate,
                'cash_total': cash,
            })

            position = 0

        # 資産評価額を記録
        portfolio_value = cash + (position * current_price if position > 0 else 0)
        portfolio_values.append(portfolio_value)

    # 最終的にポジションが残っていたら売却
    if position > 0:
        final_price = df['Close'].iloc[-1]
        revenue = position * final_price * (1 - commission)
        cash += revenue

    # バックテスト結果を計算
    total_return = (cash - initial_cash) / initial_cash
    final_value = max(portfolio_values)
    max_drawdown = calculate_max_drawdown(portfolio_values, initial_cash)

    result = {
        'trades': trades,
        'portfolio_values': portfolio_values,
        'final_cash': cash,
        'total_return': total_return,
        'max_drawdown': max_drawdown,
        'num_trades': len([t for t in trades if t['type'] == 'BUY']),
        'win_rate': calculate_win_rate(trades),
    }

    return result

def calculate_max_drawdown(portfolio_values, initial_cash):
    """最大ドローダウン(最大損失率)を計算"""
    if not portfolio_values:
        return 0

    running_max = max(portfolio_values[0], initial_cash)
    max_dd = 0

    for value in portfolio_values:
        running_max = max(running_max, value)
        drawdown = (running_max - value) / running_max
        max_dd = max(max_dd, drawdown)

    return max_dd

def calculate_win_rate(trades):
    """勝率を計算"""
    sell_trades = [t for t in trades if t['type'] == 'SELL']

    if not sell_trades:
        return 0

    wins = sum(1 for t in sell_trades if t['profit'] > 0)
    return wins / len(sell_trades)

# ==============================
# 結果表示
# ==============================
def display_backtest_results(result, initial_cash):
    """バックテスト結果をレポート表示"""
    print("\n" + "="*70)
    print("バックテスト結果レポート")
    print("="*70)

    print(f"\n【成績】")
    print(f"初期資金: ¥{initial_cash:,.0f}")
    print(f"最終資金: ¥{result['final_cash']:,.0f}")
    print(f"総リターン: {result['total_return']*100:.2f}%")
    print(f"最大ドローダウン: {result['max_drawdown']*100:.2f}%")
    print(f"取引回数: {result['num_trades']}回")
    print(f"勝率: {result['win_rate']*100:.1f}%")

    if result['trades']:
        print(f"\n【取引履歴(最新5件)】")
        for trade in result['trades'][-5:]:
            print(f"{trade['date'].strftime('%Y-%m-%d')} {trade['type']:5} "
                  f"¥{trade['price']:,.0f} × {trade['shares']}株", end="")
            if trade['type'] == 'SELL':
                print(f" → 利益 ¥{trade['profit']:,.0f} ({trade['profit_rate']*100:+.2f}%)")
            else:
                print()

# ==============================
# メイン処理
# ==============================
if __name__ == "__main__":
    # データ取得
    df = fetch_data(SYMBOL, START_DATE, END_DATE)

    if df is not None:
        # 移動平均線を計算
        df = calculate_moving_averages(df, MA_SHORT, MA_LONG)

        # シグナルを生成
        signals = generate_signals(df)

        # バックテスト実行
        result = backtest(df, signals, INITIAL_CASH, COMMISSION)

        # 結果表示
        display_backtest_results(result, INITIAL_CASH)

このコードの処理フロー:

  • fetch_data() でyfinanceから過去5年分の株価データを取得
  • calculate_moving_averages() で20日と50日の移動平均線を計算
  • generate_signals() でゴールデンクロス・デッドクロスを検出
  • backtest() で実際の売買をシミュレート、手数料も考慮
  • ポートフォリオの推移、勝率、最大ドローダウンなどを計算

開発テスト vs 検証テストの厳格な分離

バックテストで高い成績が出ても、それが実運用で通用するかは別問題です。これを検証するために、データを時系列で分割してテストする必要があります。

【コピペOK】訓練データと検証データの分割テスト

import numpy as np
import pandas as pd
import yfinance as yf
from datetime import datetime
import matplotlib.pyplot as plt

# ==============================
# 設定エリア
# ==============================
SYMBOL = "7203.T"
START_DATE = "2019-01-01"
END_DATE = "2023-12-31"

# テスト期間の分割
TRAIN_END_DATE = "2022-12-31"   # 訓練期間:2019年~2022年
TEST_START_DATE = "2023-01-01"  # 検証期間:2023年

MA_SHORT_RANGE = range(15, 31, 2)  # 15, 17, 19, ..., 29日
MA_LONG_RANGE = range(40, 61, 5)   # 40, 45, 50, 55, 60日

INITIAL_CASH = 1000000
COMMISSION = 0.001

# ==============================
# バックテスト関数(前のコードから流用)
# ==============================
def fetch_data(symbol, start_date, end_date):
    """データ取得"""
    print(f"[{datetime.now()}] {symbol} のデータ取得中...")
    df = yf.download(symbol, start=start_date, end=end_date, progress=False)
    return df

def calculate_moving_averages(df, short, long):
    """移動平均線を計算"""
    df['MA_SHORT'] = df['Close'].rolling(window=short).mean()
    df['MA_LONG'] = df['Close'].rolling(window=long).mean()
    return df

def generate_signals(df):
    """シグナルを生成"""
    signals = pd.Series(0, index=df.index)

    for i in range(1, len(df)):
        prev_short = df['MA_SHORT'].iloc[i-1]
        prev_long = df['MA_LONG'].iloc[i-1]
        curr_short = df['MA_SHORT'].iloc[i]
        curr_long = df['MA_LONG'].iloc[i]

        if pd.isna(prev_short) or pd.isna(curr_short):
            continue

        if prev_short < prev_long and curr_short > curr_long:
            signals.iloc[i] = 1
        elif prev_short > prev_long and curr_short < curr_long:
            signals.iloc[i] = -1

    return signals

def backtest(df, signals, initial_cash, commission):
    """バックテスト実行"""
    cash = initial_cash
    position = 0
    entry_price = 0
    trades = []
    portfolio_values = []

    for i in range(len(df)):
        current_price = df['Close'].iloc[i]
        signal = signals.iloc[i]

        if signal == 1 and position == 0:
            shares = int(cash / current_price * (1 - commission))
            if shares > 0:
                cost = shares * current_price * (1 + commission)
                cash -= cost
                position = shares
                entry_price = current_price
                trades.append({'date': df.index[i], 'type': 'BUY', 'price': current_price})

        elif signal == -1 and position > 0:
            revenue = position * current_price * (1 - commission)
            profit = revenue - (entry_price * position)
            cash += revenue
            trades.append({'date': df.index[i], 'type': 'SELL', 'price': current_price, 'profit': profit})
            position = 0

        portfolio_value = cash + (position * current_price if position > 0 else 0)
        portfolio_values.append(portfolio_value)

    if position > 0:
        final_price = df['Close'].iloc[-1]
        revenue = position * final_price * (1 - commission)
        cash += revenue

    total_return = (cash - initial_cash) / initial_cash

    return {
        'trades': trades,
        'final_cash': cash,
        'total_return': total_return,
        'num_trades': len([t for t in trades if t['type'] == 'BUY']),
    }

# ==============================
# パラメータ最適化(訓練データ)
# ==============================
def optimize_parameters(df_train, ma_short_range, ma_long_range):
    """
    訓練データで複数のパラメータ組み合わせをテスト

    Returns:
        list: 各パラメータ組み合わせの成績(降順ソート)
    """
    print(f"\n【訓練期間でのパラメータ最適化】")
    print(f"テスト組み合わせ数: {len(ma_short_range) * len(ma_long_range)}")

    results = []

    for ma_short in ma_short_range:
        for ma_long in ma_long_range:
            if ma_short >= ma_long:
                continue

            df_test = df_train.copy()
            df_test = calculate_moving_averages(df_test, ma_short, ma_long)
            signals = generate_signals(df_test)
            result = backtest(df_test, signals, INITIAL_CASH, COMMISSION)

            results.append({
                'ma_short': ma_short,
                'ma_long': ma_long,
                'total_return': result['total_return'],
                'num_trades': result['num_trades'],
            })

    # リターンでソート(降順)
    results.sort(key=lambda x: x['total_return'], reverse=True)

    return results

# ==============================
# 検証テスト
# ==============================
def validate_on_test_data(df_train, df_test, ma_short, ma_long):
    """
    訓練データで最適化したパラメータを検証データでテスト

    Returns:
        dict: 訓練期間の成績と検証期間の成績を比較
    """
    # 訓練期間での成績
    df_train_copy = df_train.copy()
    df_train_copy = calculate_moving_averages(df_train_copy, ma_short, ma_long)
    signals_train = generate_signals(df_train_copy)
    result_train = backtest(df_train_copy, signals_train, INITIAL_CASH, COMMISSION)

    # 検証期間での成績
    df_test_copy = df_test.copy()
    df_test_copy = calculate_moving_averages(df_test_copy, ma_short, ma_long)
    signals_test = generate_signals(df_test_copy)
    result_test = backtest(df_test_copy, signals_test, INITIAL_CASH, COMMISSION)

    return {
        'ma_short': ma_short,
        'ma_long': ma_long,
        'train_return': result_train['total_return'],
        'test_return': result_test['total_return'],
        'overfitting_ratio': result_train['total_return'] / result_test['total_return'] if result_test['total_return'] > 0 else 0,
    }

# ==============================
# メイン処理
# ==============================
if __name__ == "__main__":
    # データ取得と期間分割
    df_full = fetch_data(SYMBOL, START_DATE, END_DATE)

    # 訓練期間と検証期間に分割
    df_train = df_full[df_full.index <= TRAIN_END_DATE]
    df_test = df_full[(df_full.index >= TEST_START_DATE) & (df_full.index <= END_DATE)]

    print(f"訓練期間: {df_train.index[0].date()} ~ {df_train.index[-1].date()}")
    print(f"検証期間: {df_test.index[0].date()} ~ {df_test.index[-1].date()}")

    # パラメータ最適化(訓練データ)
    train_results = optimize_parameters(df_train, MA_SHORT_RANGE, MA_LONG_RANGE)

    print("\n【訓練期間での TOP 5 パラメータ】")
    for rank, result in enumerate(train_results[:5], 1):
        print(f"{rank}. MA短:{result['ma_short']:2d}日, MA長:{result['ma_long']:2d}日 → "
              f"リターン {result['total_return']*100:+6.2f}%")

    # 最適パラメータで検証テスト
    best_params = train_results[0]
    validation = validate_on_test_data(df_train, df_test, 
                                      best_params['ma_short'], 
                                      best_params['ma_long'])

    print(f"\n【最適パラメータの検証結果】")
    print(f"パラメータ: MA短期{validation['ma_short']}日, MA長期{validation['ma_long']}日")
    print(f"訓練期間のリターン: {validation['train_return']*100:+.2f}%")
    print(f"検証期間のリターン: {validation['test_return']*100:+.2f}%")
    print(f"過学習度(訓練/検証): {validation['overfitting_ratio']:.2f}x")

    # 過学習判定
    if validation['overfitting_ratio'] > 1.5:
        print("\n⚠️ 警告: 過学習の可能性が高い(訓練期間のリターンが検証期間の1.5倍以上)")
    elif validation['test_return'] < 0:
        print("\n⚠️ 警告: 検証期間でマイナスリターン。実運用は推奨されません。")
    else:
        print("\n✅ 検証完了。実運用への移行を検討してください。")

このコードの処理フロー:

  • optimize_parameters() で訓練データ(2019~2022年)から最適パラメータを探索
  • 複数のパラメータ組み合わせをテストし、リターンでランキング
  • validate_on_test_data() で最適パラメータを検証データ(2023年)でテスト
  • 「過学習度」(訓練期間のリターン/検証期間のリターン)を計算
  • この比率が1.5倍以上なら、過学習の可能性を警告

リスク指標とシャープレシオによる客観的評価

バックテスト結果を評価する際、「リターン率だけ」を見るのは危険です。同じリターンでも、そこに至る道のりが異なる場合があります。リスク調整後のリターンを評価することが重要です。

【コピペOK】シャープレシオと各種リスク指標の計算

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

# ==============================
# 設定エリア
# ==============================
SYMBOL = "7203.T"
START_DATE = "2020-01-01"
END_DATE = "2023-12-31"
MA_SHORT = 20
MA_LONG = 50
INITIAL_CASH = 1000000
COMMISSION = 0.001
RISK_FREE_RATE = 0.01  # リスクフリーレート(1%)

# ==============================
# データ取得とシグナル生成(前のコードから流用)
# ==============================
def fetch_data(symbol, start, end):
    """データ取得"""
    return yf.download(symbol, start=start, end=end, progress=False)

def calculate_moving_averages(df, short, long):
    """移動平均線を計算"""
    df['MA_SHORT'] = df['Close'].rolling(window=short).mean()
    df['MA_LONG'] = df['Close'].rolling(window=long).mean()
    return df

def generate_signals(df):
    """シグナルを生成"""
    signals = pd.Series(0, index=df.index)

    for i in range(1, len(df)):
        prev_short = df['MA_SHORT'].iloc[i-1]
        prev_long = df['MA_LONG'].iloc[i-1]
        curr_short = df['MA_SHORT'].iloc[i]
        curr_long = df['MA_LONG'].iloc[i]

        if pd.isna(prev_short) or pd.isna(curr_short):
            continue

        if prev_short < prev_long and curr_short > curr_long:
            signals.iloc[i] = 1
        elif prev_short > prev_long and curr_short < curr_long:
            signals.iloc[i] = -1

    return signals

# ==============================
# ポートフォリオ日次リターンの計算
# ==============================
def backtest_with_daily_returns(df, signals, initial_cash, commission):
    """
    バックテストを実行し、日次リターンも記録

    Returns:
        dict: 取引成績と日次リターンのSeries
    """
    cash = initial_cash
    position = 0
    entry_price = 0
    portfolio_values = [initial_cash]

    for i in range(len(df)):
        current_price = df['Close'].iloc[i]
        signal = signals.iloc[i]

        if signal == 1 and position == 0:
            shares = int(cash / current_price * (1 - commission))
            if shares > 0:
                cost = shares * current_price * (1 + commission)
                cash -= cost
                position = shares
                entry_price = current_price

        elif signal == -1 and position > 0:
            revenue = position * current_price * (1 - commission)
            cash += revenue
            position = 0

        portfolio_value = cash + (position * current_price if position > 0 else 0)
        portfolio_values.append(portfolio_value)

    if position > 0:
        final_price = df['Close'].iloc[-1]
        revenue = position * final_price * (1 - commission)
        cash += revenue

    # 日次リターンを計算
    daily_returns = pd.Series(portfolio_values).pct_change().dropna()

    return {
        'final_cash': cash,
        'total_return': (cash - initial_cash) / initial_cash,
        'portfolio_values': portfolio_values,
        'daily_returns': daily_returns,
    }

# ==============================
# リスク指標の計算
# ==============================
def calculate_risk_metrics(daily_returns, portfolio_values, initial_cash, 
                          total_return, risk_free_rate=0.01):
    """
    各種リスク指標を計算

    Returns:
        dict: シャープレシオ、ソーティーノレシオ、カルマーレシオなど
    """
    # 年間ボラティリティ(標準偏差)
    annual_volatility = daily_returns.std() * np.sqrt(252)

    # シャープレシオ = (年間リターン - リスクフリーレート) / 年間ボラティリティ
    annual_return = total_return  # 期間が1年の場合、そのままの値
    sharpe_ratio = (annual_return - risk_free_rate) / annual_volatility if annual_volatility > 0 else 0

    # ソーティーノレシオ = (年間リターン - リスクフリーレート) / ダウンサイドボラティリティ
    downside_returns = daily_returns[daily_returns < 0]
    downside_volatility = downside_returns.std() * np.sqrt(252) if len(downside_returns) > 0 else 0
    sortino_ratio = (annual_return - risk_free_rate) / downside_volatility if downside_volatility > 0 else 0

    # 最大ドローダウン
    running_max = np.maximum.accumulate(portfolio_values)
    drawdown = (np.array(portfolio_values) - running_max) / running_max
    max_drawdown = np.min(drawdown)

    # カルマーレシオ = 年間リターン / 最大ドローダウン(絶対値)
    calmar_ratio = annual_return / abs(max_drawdown) if max_drawdown != 0 else 0

    # 収益因子(総利益 / 総損失)
    winning_days = len(daily_returns[daily_returns > 0])
    losing_days = len(daily_returns[daily_returns < 0])
    win_rate = winning_days / (winning_days + losing_days) if (winning_days + losing_days) > 0 else 0

    return {
        'annual_volatility': annual_volatility,
        'sharpe_ratio': sharpe_ratio,
        'sortino_ratio': sortino_ratio,
        'max_drawdown': max_drawdown,
        'calmar_ratio': calmar_ratio,
        'win_rate': win_rate,
        'winning_days': winning_days,
        'losing_days': losing_days,
    }

# ==============================
# 結果表示
# ==============================
def display_comprehensive_report(symbol, result, risk_metrics):
    """包括的なレポートを表示"""
    print("\n" + "="*70)
    print(f"バックテスト総合評価レポート({symbol})")
    print("="*70)

    print(f"\n【リターン・リスク指標】")
    print(f"総リターン: {result['total_return']*100:+.2f}%")
    print(f"年間ボラティリティ: {risk_metrics['annual_volatility']*100:.2f}%")
    print(f"最大ドローダウン: {risk_metrics['max_drawdown']*100:.2f}%")

    print(f"\n【リスク調整後リターン】")
    print(f"シャープレシオ: {risk_metrics['sharpe_ratio']:.3f}")
    print(f"  ※0.5以上が優良、1.0以上が優秀")
    print(f"ソーティーノレシオ: {risk_metrics['sortino_ratio']:.3f}")
    print(f"  ※下落リスクを考慮したシャープレシオ")
    print(f"カルマーレシオ: {risk_metrics['calmar_ratio']:.3f}")
    print(f"  ※ドローダウン単位のリターン、1.0以上が良好")

    print(f"\n【取引成功率】")
    print(f"勝率: {risk_metrics['win_rate']*100:.1f}%")
    print(f"勝ち日数: {risk_metrics['winning_days']}")
    print(f"負け日数: {risk_metrics['losing_days']}")

# ==============================
# メイン処理
# ==============================
if __name__ == "__main__":
    # データ取得
    df = fetch_data(SYMBOL, START_DATE, END_DATE)
    df = calculate_moving_averages(df, MA_SHORT, MA_LONG)

    # シグナル生成
    signals = generate_signals(df)

    # バックテスト実行(日次リターン付き)
    result = backtest_with_daily_returns(df, signals, INITIAL_CASH, COMMISSION)

    # リスク指標を計算
    risk_metrics = calculate_risk_metrics(
        result['daily_returns'],
        result['portfolio_values'],
        INITIAL_CASH,
        result['total_return'],
        RISK_FREE_RATE
    )

    # レポート表示
    display_comprehensive_report(SYMBOL, result, risk_metrics)

    # 判定
    print(f"\n【実運用推奨度判定】")
    if risk_metrics['sharpe_ratio'] < 0.5:
        print("⚠️ 低い: シャープレシオが0.5未満。実運用は推奨されません。")
    elif risk_metrics['sharpe_ratio'] < 1.0:
        print("△ 中程度: シャープレシオが0.5~1.0。デモ環境での運用が推奨。")
    else:
        print("✅ 高い: シャープレシオが1.0以上。実運用を検討してください。")

このコードの処理フロー:

  • 日次リターンを計算し、年間ボラティリティを算出
  • シャープレシオ(リターン/リスク)を計算
  • ソーティーノレシオ(下落リスクのみを考慮)も算出
  • 最大ドローダウンからカルマーレシオを計算
  • 総合的に戦略の質を評価

よくあるエラーと対処法

バックテストの結果が現実と大きく異なります

原因:

  • スリッページ(実際の約定価格とシグナル価格の差)を考慮していない
  • 手数料の設定が不正確(現実の手数料が0.1%ではなく0.2~0.3%かも)
  • データが分割調整されていない、または上場廃止銘柄を含む

対処法:

  • スリッページを追加設定: 買いシグナルでは価格に+0.1%、売りシグナルでは-0.1%を反映させる
  • 実際の証券会社の手数料表を確認し、設定値を修正
  • yf.download() 時に調整済み価格(Adj Close)を使用

訓練期間と検証期間で大きく成績が異なります

原因:

  • 過学習(カーブフィッティング)している
  • 市場環境が大きく変わった(例:2020年コロナショック)
  • 銘柄固有の事象(増資、分割、統合など)の影響

対処法:

  • より長期間のデータで訓練(3~5年)し、パラメータの幅を広げる
  • 複数の市場環境(上昇相場、下降相場、変動相場)でテストする
  • 複数銘柄でテストし、汎用性を確認

シャープレシオが負になります

原因:

  • 戦略がマイナスリターンを生成している
  • ボラティリティが非常に高い

対処法:

  • 戦略のロジックを見直し、シグナル生成の条件を変更
  • テクニカル指標の組み合わせを修正
  • 市場環境に合わせてパラメータを動的に変更する仕組みを検討

バックテストは利益が出ているのに実運用で損失が出ます

原因:

  • 実運用では完全な執行(スリッページなし、手数料ゼロ)が実現不可能
  • バックテスト期間の相場が特殊だった(過去データに最適化)
  • システムエラーやネットワーク遅延により、シグナル通りに取引できていない

対処法:

  • シミュレーション時に実際より保守的な想定を使用(スリッページ+0.2%など)
  • 複数の市場環境を含むより長期のデータでテストを再実施
  • デモ口座で3~6ヶ月の前向きテストを実施してから本運用へ移行

まとめ

本記事では、Pythonでバックテストを正しく実装し、開発テストと検証テストを厳格に分離し、リスク指標を用いた客観的評価までを網羅しました。

要点を整理します。

  • バックテストの目的は「完璧な過去成績を探す」ではなく「実運用で機能する戦略を検証する」こと
  • 過学習を避けるため、訓練データと検証データを時系列で分割してテストが必須
  • シャープレシオ、ソーティーノレシオ、カルマーレシオなど複数の指標で総合評価が重要
  • バックテスト期間と実運用期間の相場環境が異なることを常に認識する
  • 最大ドローダウンとリスク指標を念頭に、リターンだけで判断しない

次のステップとしては、本記事のコードで複数の銘柄、複数のパラメータを試験し、どの組み合わせが最も堅牢な結果を出すかを調査することをお勧めします。その後、デモ口座で3~6ヶ月の「前向きテスト」を実施してから、本運用に移行してください。

バックテストはあくまで「過去の検証」です。本当の試験は、実運用で初めて始まります。

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