Pythonで移動平均クロス戦略をバックテスト・グリッドサーチ最適化する方法|ウォークフォワード

Python実装・コード

移動平均クロス戦略は、短期移動平均が長期移動平均を上抜けたら買い、下抜けたら売るシンプルなトレンドフォロー戦略です。実装は簡単ですが、パラメータ次第で収益性が大きく変わります。ということで、この記事ではバックテストの実装からグリッドサーチによる最適化まで手順をまとめます。

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

📘 外部参考Trend Trading(Investopedia)

📘 外部参考移動平均(Wikipedia 日本語)Moving Average(Investopedia)

📘 外部参考Golden Cross(Investopedia)ゴールデンクロス(Wikipedia)

基本的な移動平均クロス戦略

import yfinance as yf
import pandas as pd
import numpy as np
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import itertools

def get_data(ticker, period='5y'):
    df = yf.download(ticker, period=period, progress=False)
    return df[['Close', 'Volume']].copy()

def moving_average_cross(df, short=20, long=60):
    """移動平均クロス戦略のシグナル生成"""
    df = df.copy()
    df[f'MA{short}'] = df['Close'].rolling(short).mean()
    df[f'MA{long}'] = df['Close'].rolling(long).mean()
    
    # ゴールデンクロス: 1, デッドクロス: -1, 保持: 0
    df['position'] = 0
    mask_buy = (df[f'MA{short}'] > df[f'MA{long}']) & (df[f'MA{short}'].shift(1) <= df[f'MA{long}'].shift(1))
    mask_sell = (df[f'MA{short}'] < df[f'MA{long}']) & (df[f'MA{short}'].shift(1) >= df[f'MA{long}'].shift(1))
    df.loc[mask_buy, 'position'] = 1
    df.loc[mask_sell, 'position'] = -1
    
    # 保有状態を維持
    df['holding'] = df['position'].replace(0, np.nan).ffill().fillna(0).clip(lower=0)
    
    return df.dropna()

df = get_data('7203.T')  # トヨタ
result = moving_average_cross(df, short=20, long=60)
print(f"シグナル数 - 買い: {(result['position']==1).sum()}, 売り: {(result['position']==-1).sum()}")

バックテストエンジン

def backtest(df, commission=0.001, slippage=0.001):
    """
    バックテストエンジン
    commission: 手数料率(0.1%)
    slippage: スリッページ(0.1%)
    """
    df = df.copy()
    capital = 1_000_000  # 初期資金100万円
    position = 0
    entry_price = 0
    
    history = []
    
    for idx, row in df.iterrows():
        price = row['Close']
        
        if row['position'] == 1 and position == 0:  # 買いシグナル
            cost = price * (1 + commission + slippage)
            shares = int(capital // cost)
            if shares > 0:
                capital -= shares * cost
                position = shares
                entry_price = cost
        
        elif row['position'] == -1 and position > 0:  # 売りシグナル
            revenue = price * (1 - commission - slippage)
            capital += position * revenue
            position = 0
        
        total = capital + position * price
        history.append(total)
    
    # 最終決済
    if position > 0:
        capital += position * df['Close'].iloc[-1]
    
    equity = pd.Series(history, index=df.index)
    
    # パフォーマンス指標
    total_return = (capital - 1_000_000) / 1_000_000
    daily_returns = equity.pct_change().dropna()
    sharpe = daily_returns.mean() / daily_returns.std() * np.sqrt(252)
    
    rolling_max = equity.cummax()
    drawdown = (equity - rolling_max) / rolling_max
    max_dd = drawdown.min()
    
    buy_hold = (df['Close'].iloc[-1] - df['Close'].iloc[0]) / df['Close'].iloc[0]
    
    return {
        'total_return': total_return,
        'sharpe_ratio': sharpe,
        'max_drawdown': max_dd,
        'buy_hold_return': buy_hold,
        'equity': equity
    }

result_df = moving_average_cross(df, 20, 60)
perf = backtest(result_df)
print(f"総リターン: {perf['total_return']:.2%}")
print(f"シャープレシオ: {perf['sharpe_ratio']:.2f}")
print(f"最大ドローダウン: {perf['max_drawdown']:.2%}")
print(f"バイ&ホールド: {perf['buy_hold_return']:.2%}")

グリッドサーチによるパラメータ最適化

def grid_search_ma(df, short_range=range(5, 30, 5), long_range=range(20, 120, 10)):
    """移動平均期間のグリッドサーチ"""
    results = []
    
    for short, long in itertools.product(short_range, long_range):
        if short >= long:
            continue
        
        try:
            result_df = moving_average_cross(df, short, long)
            if len(result_df) < long:
                continue
            perf = backtest(result_df)
            results.append({
                'short': short,
                'long': long,
                'total_return': perf['total_return'],
                'sharpe_ratio': perf['sharpe_ratio'],
                'max_drawdown': perf['max_drawdown']
            })
        except Exception:
            continue
    
    results_df = pd.DataFrame(results)
    
    # シャープレシオで最適化
    best = results_df.nlargest(5, 'sharpe_ratio')
    print("シャープレシオ上位5パラメータ:")
    print(best[['short', 'long', 'total_return', 'sharpe_ratio', 'max_drawdown']].to_string())
    
    return results_df

results = grid_search_ma(df)

# ヒートマップ描画
pivot = results.pivot(index='short', columns='long', values='sharpe_ratio')
plt.figure(figsize=(12, 6))
plt.imshow(pivot, aspect='auto', cmap='RdYlGn')
plt.colorbar(label='Sharpe Ratio')
plt.title('移動平均クロス パラメータ最適化ヒートマップ')
plt.xlabel('長期MA期間')
plt.ylabel('短期MA期間')
plt.savefig('ma_heatmap.png', dpi=100, bbox_inches='tight')
print("ヒートマップを保存しました")

過最適化を避けるウォークフォワードテスト

📘 外部参考Walk-Forward Optimization(Investopedia)

def walk_forward_test(df, train_years=2, test_years=1):
    """
    ウォークフォワードテスト
    訓練期間でパラメータを最適化し、テスト期間で評価
    """
    df = df.copy()
    df.index = pd.to_datetime(df.index)
    
    results = []
    start = df.index[0]
    end = df.index[-1]
    
    current = start + pd.DateOffset(years=train_years)
    
    while current + pd.DateOffset(years=test_years) <= end:
        train = df[df.index < current]
        test = df[(df.index >= current) & (df.index < current + pd.DateOffset(years=test_years))]
        
        # 訓練期間で最適パラメータを探索
        best_sharpe = -np.inf
        best_params = (20, 60)
        
        for short, long in [(5,20),(10,30),(20,60),(5,50),(15,45)]:
            try:
                r = moving_average_cross(train, short, long)
                p = backtest(r)
                if p['sharpe_ratio'] > best_sharpe:
                    best_sharpe = p['sharpe_ratio']
                    best_params = (short, long)
            except Exception:
                continue
        
        # テスト期間で評価
        test_result = moving_average_cross(test, *best_params)
        test_perf = backtest(test_result)
        
        results.append({
            'period': f"{current.year}",
            'params': best_params,
            'test_return': test_perf['total_return'],
            'test_sharpe': test_perf['sharpe_ratio']
        })
        
        current += pd.DateOffset(years=test_years)
    
    result_df = pd.DataFrame(results)
    print(result_df.to_string())
    print(f"
平均テストリターン: {result_df['test_return'].mean():.2%}")
    return result_df

wf_results = walk_forward_test(df)

まとめ

実際に試してみると、グリッドサーチで見つけた最適パラメータでも、別期間に当てると性能が落ちることが多いです。ウォークフォワードテストで「本当に汎化しているか」を確認してから使うのが現実的だと思っています。手数料・スリッページを無視すると実運用でかなりズレるので、そこも含めてバックテストするといいかと思います。

🔗 関連記事

Pythonで移動平均線を表示する方法【pandas・matplotlib・コードあり】

🔗 関連記事

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

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