バックテスト(backtesting、過去データでの戦略検証)は、ライブラリに頼ると内部の挙動がブラックボックスになりがちです。本記事では、外部のバックテストフレームワークを使わず、pandas だけで 最小限のバックテスト を組み立てます。中身を把握しておくと、後でフレームワークを使うときにも結果を信頼しやすくなります。
目次
- 最小実装の構成要素
- 戦略の定義
- サンプルデータの準備
- ステップ 1〜2: リターンとシグナル
- ステップ 3: ポジション(先読み防止)
- ステップ 4: 損益曲線
- 評価指標を計算する
- ベンチマーク(バイ&ホールド)との比較
- 可視化
- 関数化
- 別シグナルへの差し替え例
最小実装の構成要素
最低限のバックテストは、次の 4 ステップから成ります。
| ステップ | 内容 |
|---|---|
| 1. 価格 → リターン | 終値から日次リターンを計算 |
| 2. シグナル | 「保有する / 保有しない」のフラグを生成 |
| 3. ポジション | シグナルを 1 日遅らせて 翌日の保有状態とする |
| 4. 損益曲線 | ポジション × リターン − コストを累積 |
ステップ 3 の「1 日遅らせる」が 先読みバイアス防止 の中核です。当日のシグナルを当日の終値で約定させるのは、現実には不可能だからです(#10-8「データリーク・先読みバイアスを実例で防ぐ」)。
戦略の定義
例として、25 日 SMA と 75 日 SMA のクロスを使います。
| 項目 | 設定 |
|---|---|
| 短期 SMA | 25 日 |
| 長期 SMA | 75 日 |
| シグナル | 短期 > 長期 のとき 1、そうでなければ 0 |
| ポジション | シグナルを 1 日 shift(1) |
| 取引コスト | 片道 0.1%(ポジションが切り替わった日に発生) |
サンプルデータの準備
ランダムウォーク的な株価を生成します。実データを使う場合は、J-Quants から取得した調整済み終値に置き換えてください。
import numpy as npimport pandas as pd
rng = np.random.default_rng(seed=5)n_days = 1000
daily_ret = rng.normal(loc=0.0004, scale=0.013, size=n_days)price = 1500 * np.exp(np.cumsum(daily_ret))
prices = pd.Series( price, index=pd.date_range("2022-01-04", periods=n_days, freq="B"), name="C",)print(prices.head())ステップ 1〜2: リターンとシグナル
SHORT_WIN = 25LONG_WIN = 75
df = prices.to_frame()df["ret"] = df["C"].pct_change()df["sma_short"] = df["C"].rolling(SHORT_WIN).mean()df["sma_long"] = df["C"].rolling(LONG_WIN).mean()df["signal"] = (df["sma_short"] > df["sma_long"]).astype(int)rolling().mean() は最初の WIN - 1 日が NaN になります。シグナルもその期間は当然意味を持たないため、後段で dropna します。
ステップ 3: ポジション(先読み防止)
df["position"] = df["signal"].shift(1)df = df.dropna(subset=["position", "ret"]).copy()shift(1) を入れない実装は、当日のシグナルを当日の終値で約定させていることになり、未来情報を使った見た目だけの好成績を生みます。バックテスト結果の妥当性を疑うとき、まずチェックすべき箇所です。
ステップ 4: 損益曲線
ポジションが切り替わった日にコストを計上します。
COST = 0.001 # 片道 0.1%INITIAL_CAPITAL = 1_000_000
df["pos_change"] = df["position"].diff().abs().fillna(df["position"])df["cost"] = df["pos_change"] * COSTdf["strategy_ret"] = df["position"] * df["ret"] - df["cost"]df["equity"] = INITIAL_CAPITAL * (1 + df["strategy_ret"]).cumprod()print(df[["C", "position", "strategy_ret", "equity"]].tail())pos_change: ポジションの変化量(0 → 1 で 1、1 → 0 で 1)cost: 切り替わった日だけに、片道コストを計上strategy_ret: その日に得られた戦略リターンequity: 累積した資産曲線(複利)
評価指標を計算する
CAGR・シャープレシオ・最大ドローダウンの 3 点セットでまず確認します。
TRADING_DAYS = 252
ret = df["strategy_ret"]eq = df["equity"]n = len(ret)years = n / TRADING_DAYS
cum_return = eq.iloc[-1] / INITIAL_CAPITAL - 1cagr = (eq.iloc[-1] / INITIAL_CAPITAL) ** (1 / years) - 1 if years > 0 else float("nan")sharpe = (ret.mean() / ret.std(ddof=0)) * np.sqrt(TRADING_DAYS) if ret.std(ddof=0) > 0 else float("nan")mdd = ((eq - eq.cummax()) / eq.cummax()).min()
print(f"累積リターン: {cum_return:.4f}")print(f"CAGR: {cagr:.4f}")print(f"年率シャープ: {sharpe:.4f}")print(f"最大DD: {mdd:.4f}")ベンチマーク(バイ&ホールド)との比較
戦略リターンとは別に、初日に買って最終日まで保有した場合の損益曲線も計算します。
df["bh_equity"] = INITIAL_CAPITAL * (1 + df["ret"]).cumprod()戦略がバイ&ホールドに勝てない期間は珍しくなく、両者を一緒にプロットすると「単に上昇相場で利益が出ただけ」かどうかが視覚的に分かります。
可視化
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(9, 5))ax.plot(df.index, df["equity"], label="strategy", color="tab:blue")ax.plot(df.index, df["bh_equity"], label="buy & hold", color="tab:gray", alpha=0.7)ax.set_title(f"SMA({SHORT_WIN}/{LONG_WIN}) cross — equity curves")ax.set_ylabel("Eq (JPY)")ax.set_xlabel("Date")ax.grid(alpha=0.3)ax.legend()plt.tight_layout()plt.savefig("backtest_minimal.png", dpi=120)plt.close(fig)
関数化
再利用しやすいように、戦略部分を関数にまとめます。
def backtest( prices: pd.Series, signal: pd.Series, cost: float = 0.001, initial: float = 1_000_000,) -> pd.DataFrame: """価格と 0/1 シグナルからバックテスト結果を返す。
Notes ----- シグナルは 1 日 shift(1) してポジションに変換する(先読み防止)。 """ df = pd.DataFrame({"C": prices, "signal": signal}) df["ret"] = df["C"].pct_change() df["position"] = df["signal"].shift(1) df = df.dropna(subset=["position", "ret"]).copy()
df["pos_change"] = df["position"].diff().abs().fillna(df["position"]) df["cost"] = df["pos_change"] * cost df["strategy_ret"] = df["position"] * df["ret"] - df["cost"] df["equity"] = initial * (1 + df["strategy_ret"]).cumprod() return dfシグナル生成と評価を分離しておくと、戦略の差し替えが楽になります。
別シグナルへの差し替え例
たとえば「20 日高値ブレイクアウト」のシグナル例です。
high_20 = df["C"].rolling(20).max()breakout_signal = (df["C"] >= high_20).astype(int)result = backtest(df["C"], breakout_signal)戦略部分のロジックを変えるだけで、同じ評価が回せます。
注意点
shift(1)を必ず入れる: 抜けたら結果は信頼できなくなります(#10-8「データリーク・先読みバイアスを実例で防ぐ」)- 取引コストを 0 にしない: 横ばい相場で結果が極端に変わります
- 配当・株式分割: 調整済み終値を使うこと(調整なしだと分割の日に偽のリターンが入る)
- 過剰最適化: 25/75 という日数を「過去の勝てた数字」に合わせると、未来データで機能しなくなります
- 複数銘柄・期間で検証: 1 銘柄・1 期間の結果に頼らず、時系列クロスバリデーションへ進む(#10-9「時系列クロスバリデーションと訓練/検証分割」)
本記事のコードはバックテスト用で、実発注 API は呼びません。実取引に流用する場合は、注文金額の上限・誤発注防止のチェックなどを別途実装する必要があります。
生成AI へのプロンプト例
戦略を差し替えやすい形でリファクタしたい場合の例です。
次の関数 backtest(prices, signal, cost, initial) があります。
仕様:- prices, signal は同じ DatetimeIndex を持つ pandas Series- signal は 0/1 のロング・キャッシュ- ポジションは shift(1) 後の signal- 片道コストはポジションが切り替わった日だけに計上
この関数を、複数銘柄(DataFrame: index=Date, columns=Code)を一括でバックテストする backtest_panel(prices, signals, cost, initial) に拡張してください。戻り値は次の DataFrame: - 列: Code - 値: 戦略リターン (Date を index)
要件:- pandas 2.2 / numpy 系- 銘柄ごとに先読み防止 shift(1) を行う- docstring を日本語で書くまとめ
- 最小バックテストは「リターン → シグナル → 1 日ずらしポジション → 損益曲線」の 4 ステップ
shift(1)で先読みバイアスを防ぐ- コストはポジション切り替え時だけに計上
- バイ&ホールドと比較して相対評価する
- CAGR・シャープ・MDD の 3 点セットを最低限見る