バックテストで年利25%が出たのに、実運用で最初の2週間で損した話【過学習・カーブフィッティング対策】

Uncategorized

バックテストで年利25%が出たのに、実運用で最初の2週間で損した話

先月、ようやく自作のBOTを本番稼働させたんですよ。バックテストでは年利約25%という夢のような数字が出てたんで、「これは行けるな」と思って少額から始めたんですが。。。最初の2週間でじわじわ損失が出て、ちょっと焦りました笑。

なんで差が出るんだろうと調べたら、「カーブフィッティング(過学習)」という概念にたどり着きました。これ、アルゴトレーダーにとってはかなりあるある問題らしく、理解してからバックテストの見方が変わったので記事にまとめます。

カーブフィッティングとは何か

カーブフィッティング(過学習 / オーバーフィッティング)とは、過去のデータに対してパラメータを最適化しすぎた結果、未来のデータには全く通用しないモデルになってしまう現象のことです。

例えば「日本製鉄(5401)の過去3年のデータで、移動平均のパラメータを調整しまくった結果、バックテスト上では素晴らしい成績が出た」としても、それはその3年間の「偶然の価格パターン」に最適化されただけかもしれません。4年目以降(未来)のデータでは機能しないケースが多いです。

パラメータを細かく調整すればするほどバックテスト成績は上がりやすいですが、それは「過去の特定パターン」を暗記しているだけ。相場環境が変わった途端に崩壊します。

なぜ起きるのか:「自由度」の問題

統計的に説明すると、パラメータの数(自由度)が多ければ多いほど、どんなデータにも「それなりに合う曲線」を引けてしまいます。移動平均の期間・RSIの閾値・損切りライン・利確ラインをすべて最適化したら、過去5年のデータに完全にフィットするモデルは作れます。でもそれは未来を予測していません。

カーブフィッティングを避けるための3つの実践的アプローチ

① アウトオブサンプル(OOS)テストを必ず実施する

データを「学習期間」と「検証期間」に分けるのが基本です。例えば2019〜2023年のデータで戦略を最適化したら、一切触れていない2024〜2025年のデータで検証します。この検証期間(OOS)でも同様の成績が出れば、本物の可能性が高いです。

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

# 日本製鉄(5401.T)のデータ取得
ticker = "5401.T"
df = yf.download(ticker, start="2019-01-01", end="2026-01-01")
df = df[["Close"]].copy()

# 学習期間とOOS期間を分割
train_end = "2023-12-31"
oos_start = "2024-01-01"

df_train = df[df.index <= train_end].copy()
df_oos   = df[df.index >= oos_start].copy()

print(f"学習期間: {df_train.index[0].date()} 〜 {df_train.index[-1].date()}  ({len(df_train)}日)")
print(f"OOS期間:  {df_oos.index[0].date()} 〜 {df_oos.index[-1].date()}  ({len(df_oos)}日)")

def backtest_sma_cross(data, short_win, long_win):
    """シンプルなSMAクロス戦略のバックテスト"""
    d = data.copy()
    d["sma_short"] = d["Close"].rolling(short_win).mean()
    d["sma_long"]  = d["Close"].rolling(long_win).mean()
    d["signal"] = np.where(d["sma_short"] > d["sma_long"], 1, -1)
    d["ret"]    = d["Close"].pct_change()
    d["strat"]  = d["signal"].shift(1) * d["ret"]
    total_return = (1 + d["strat"].dropna()).prod() - 1
    return total_return

# 学習期間でパラメータを探索(過学習リスクあり)
best_return = -np.inf
best_params = (0, 0)
for s in range(5, 30, 5):
    for l in range(30, 120, 10):
        r = backtest_sma_cross(df_train, s, l)
        if r > best_return:
            best_return = r
            best_params = (s, l)

print(f"\n最適パラメータ: short={best_params[0]}, long={best_params[1]}")
print(f"学習期間リターン: {best_return:.1%}")

# OOS期間で検証(ここが本番)
oos_return = backtest_sma_cross(df_oos, best_params[0], best_params[1])
print(f"OOS期間リターン: {oos_return:.1%}  ← これが本物の実力")

② パラメータ感度分析(ロバストネステスト)

「best_params」の前後でも同じくらいの成績が出るか確認します。最適値だけ飛び抜けて良く、周辺パラメータが悲惨なら過学習のサインです。

import matplotlib.pyplot as plt
import matplotlib
matplotlib.rcParams['font.family'] = 'IPAGothic'  # 日本語対応フォント

short_w, long_w = best_params
results = {}

for s in range(short_w - 10, short_w + 15, 5):
    for l in range(long_w - 20, long_w + 25, 10):
        if s < l and s > 0:
            r = backtest_sma_cross(df_oos, s, l)
            results[(s, l)] = r

# 結果を表示
print("\n感度分析(OOS期間):")
print(f"{'short':>6} {'long':>6} {'return':>10}")
for (s, l), r in sorted(results.items()):
    marker = " ← 最適値" if (s, l) == best_params else ""
    print(f"{s:>6} {l:>6} {r:>10.1%}{marker}")

# 収益が安定していれば本物、最適値だけ突出なら過学習

③ ウォークフォワード最適化

「1年ごとに再最適化→翌年で検証」を繰り返す方法です。より本番運用に近い条件でのテストになります。

def walk_forward_test(data, train_years=3, test_years=1):
    """ウォークフォワード最適化"""
    results = []
    data = data.copy()
    data.index = pd.to_datetime(data.index)

    start_year = data.index[0].year
    end_year   = data.index[-1].year

    for test_year in range(start_year + train_years, end_year + 1, test_years):
        train_start = pd.Timestamp(f"{test_year - train_years}-01-01")
        train_end   = pd.Timestamp(f"{test_year - 1}-12-31")
        test_start  = pd.Timestamp(f"{test_year}-01-01")
        test_end    = pd.Timestamp(f"{test_year + test_years - 1}-12-31")

        d_train = data[(data.index >= train_start) & (data.index <= train_end)]
        d_test  = data[(data.index >= test_start)  & (data.index <= test_end)]

        if len(d_train) < 100 or len(d_test) < 20:
            continue

        # 学習期間で最適化
        best_r, best_p = -np.inf, (10, 50)
        for s in range(5, 30, 5):
            for l in range(30, 100, 10):
                r = backtest_sma_cross(d_train, s, l)
                if r > best_r:
                    best_r, best_p = r, (s, l)

        # 検証期間で評価
        test_r = backtest_sma_cross(d_test, best_p[0], best_p[1])
        results.append({
            "test_period": f"{test_year}",
            "best_params": best_p,
            "train_return": best_r,
            "test_return": test_r
        })
        print(f"{test_year}年: params={best_p}, 学習={best_r:.1%}, 検証={test_r:.1%}")

    return results

print("\n=== ウォークフォワード最適化 ===")
wf_results = walk_forward_test(df)

まとめ:バックテストは「検証のスタート地点」でしかない

バックテストで高いリターンが出ても、それだけで「勝てるBOT」の証明にはなりません。OOSテスト・感度分析・ウォークフォワードの3つをセットで確認することで、初めて「そこそこ信頼できるかも」というラインに立てます。

僕は最初にこれを知らずにパラメータを最適化しまくった結果、立派なカーブフィッティングBOTを作り上げてしまいました笑。今は学習期間とOOS期間を最初に分けることを鉄則にしています。次は銘柄ごとのOOSリターン分布を可視化して、安定感のある銘柄を選ぶ仕組みを作ろうと思っています。同じ失敗をした方はぜひ試してみてください。

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