※本記事のコードや情報は執筆時点の仕様に基づいています。投資は自己責任であり、必ずデモ環境や少額資金でテストした上で運用してください。
バックテストを組み、移動平均のクロスやRSIの閾値で売買シグナルを出す仕組みはすでに動いている段階でしょう。
しかし、その結果が「理想的すぎる」と感じたことはないでしょうか。バックテストにスリッページ(Slippage:注文価格と実際の約定価格のズレ)を組み込むことは、現実の損益に近づけるために不可欠です。
多くの初中級者が「手数料は入れたがスリッページは無視している」状態で成績を過大評価しています。
原因は明確で、スリッページの大きさを定量的にモデル化する方法がわからないからです。固定幅で入れるべきか、出来高に応じて変動させるべきか、判断基準がありません。
本記事では、スリッページと手数料をバックテストに組み込むPythonコードを2パターン(固定スリッページ版・出来高連動スリッページ版)提示します。コードの処理フロー、パラメータの調整指針、結果の読み解き方まで一気に解説します。
コードはすべてコピペで動く構成です。自分の既存バックテストに関数単位で組み込めるので、すぐに「辛口テスト」へアップグレードしてください。
スリッページが利益を蝕むメカニズム
スリッページとは何か
スリッページとは、発注した価格と実際に約定した価格の差のことです。成行注文では板の流動性によって必ず発生し、指値注文でも急変動時にはズレが生じます。
たとえば、終値1,000円で「買い」のシグナルが出ても、翌日の実際の約定価格が1,003円になれば、3円分が不利な方向にずれています。この3円がスリッページです。
バックテストの過大評価が起きる理由
多くのバックテストは「シグナルが出た足の終値でぴったり約定した」と仮定しています。しかし現実には、終値ぴったりの約定はほぼ不可能です。
この「理想約定」の積み重ねが、数十〜数百回のトレードを経て大きな乖離になります。年間リターンが5%程度の戦略なら、スリッページだけで利益が消える可能性すらあります。
手数料とスリッページの違い
手数料(Commission)は証券会社が公表している確定コストです。一方、スリッページは相場状況で変動する不確定コストです。
バックテストでは両方を加味する必要がありますが、見積もりが難しいのはスリッページの方です。本記事では両コストを分離して管理する設計を採用しています。
【コピペOK】固定スリッページ付きバックテスト
まず必要なライブラリをインストールしてください。
pip install pandas numpy yfinance matplotlib
以下が固定スリッページと手数料を組み込んだバックテストコードです。
import numpy as np
import pandas as pd
import yfinance as yf
import matplotlib.pyplot as plt
from typing import Tuple
# ==============================
# 設定エリア
# ==============================
TICKER: str = "7203.T" # 銘柄コード(トヨタ自動車)
START_DATE: str = "2020-01-01" # データ取得開始日
END_DATE: str = "2024-12-31" # データ取得終了日
SHORT_WINDOW: int = 5 # 短期移動平均の期間
LONG_WINDOW: int = 25 # 長期移動平均の期間
INITIAL_CAPITAL: float = 1_000_000.0 # 初期資金(円)
SLIPPAGE_PCT: float = 0.05 # スリッページ率(%):片道0.05%
COMMISSION_PCT: float = 0.05 # 手数料率(%):片道0.05%
TRADE_UNIT: int = 100 # 売買単位(株)
# ==============================
# データ取得
# ==============================
def fetch_ohlcv(ticker: str, start: str, end: str) -> pd.DataFrame:
'データを取得して整形する'
df: pd.DataFrame = yf.download(ticker, start=start, end=end, auto_adjust=True)
df = df.droplevel("Ticker", axis=1) if isinstance(df.columns, pd.MultiIndex) else df
df.columns = [c.lower() for c in df.columns]
return df
# ==============================
# シグナル生成
# ==============================
def generate_signals(df: pd.DataFrame, short_w: int, long_w: int) -> pd.DataFrame:
'ゴールデンクロス・デッドクロスでシグナルを生成する'
df = df.copy()
df["sma_short"] = df["close"].rolling(window=short_w).mean()
df["sma_long"] = df["close"].rolling(window=long_w).mean()
df["signal"] = 0
df.loc[df["sma_short"] > df["sma_long"], "signal"] = 1
df.loc[df["sma_short"] <= df["sma_long"], "signal"] = -1
df["position"] = df["signal"].diff()
return df.dropna()
# ==============================
# スリッページ適用価格の算出
# ==============================
def apply_slippage(price: float, side: str, slippage_pct: float) -> float:
'買いなら上乗せ、売りなら下乗せして約定価格を返す'
if side == "buy":
return price * (1.0 + slippage_pct / 100.0)
return price * (1.0 - slippage_pct / 100.0)
# ==============================
# 手数料の算出
# ==============================
def calc_commission(price: float, shares: int, commission_pct: float) -> float:
'約定代金に対する手数料を返す'
return price * shares * (commission_pct / 100.0)
# ==============================
# バックテスト実行
# ==============================
def run_backtest(
df: pd.DataFrame,
initial_capital: float,
slippage_pct: float,
commission_pct: float,
trade_unit: int,
) -> Tuple[pd.DataFrame, pd.DataFrame]:
'スリッページ・手数料込みのバックテストを実行する'
cash: float = initial_capital
shares: int = 0
trades: list = []
equity_curve: list = []
for i, row in df.iterrows():
price: float = row["close"]
# --- 買いエントリー ---
if row["position"] == 2 and shares == 0:
exec_price: float = apply_slippage(price, "buy", slippage_pct)
buy_shares: int = (int(cash / (exec_price * trade_unit))) * trade_unit
if buy_shares >= trade_unit:
cost: float = exec_price * buy_shares
fee: float = calc_commission(exec_price, buy_shares, commission_pct)
cash -= (cost + fee)
shares = buy_shares
trades.append({
"date": i, "side": "buy", "price": price,
"exec_price": exec_price, "shares": buy_shares,
"commission": fee, "slippage_cost": abs(exec_price - price) * buy_shares,
})
# --- 売りエグジット ---
elif row["position"] == -2 and shares > 0:
exec_price = apply_slippage(price, "sell", slippage_pct)
revenue: float = exec_price * shares
fee = calc_commission(exec_price, shares, commission_pct)
cash += (revenue - fee)
trades.append({
"date": i, "side": "sell", "price": price,
"exec_price": exec_price, "shares": shares,
"commission": fee, "slippage_cost": abs(price - exec_price) * shares,
})
shares = 0
equity: float = cash + shares * price
equity_curve.append({"date": i, "equity": equity})
df_trades: pd.DataFrame = pd.DataFrame(trades)
df_equity: pd.DataFrame = pd.DataFrame(equity_curve).set_index("date")
return df_trades, df_equity
# ==============================
# 結果の表示
# ==============================
def show_summary(
df_trades: pd.DataFrame,
df_equity: pd.DataFrame,
initial_capital: float,
) -> None:
'トレード結果のサマリーを表示する'
final_equity: float = df_equity["equity"].iloc[-1]
total_return: float = (final_equity - initial_capital) / initial_capital * 100
total_slippage: float = df_trades["slippage_cost"].sum() if len(df_trades) > 0 else 0.0
total_commission: float = df_trades["commission"].sum() if len(df_trades) > 0 else 0.0
print("=" * 50)
print(f"初期資金 : {initial_capital:>14,.0f} 円")
print(f"最終資産 : {final_equity:>14,.0f} 円")
print(f"トータルリターン : {total_return:>13.2f} %")
print(f"取引回数 : {len(df_trades):>14} 回")
print(f"累計スリッページ : {total_slippage:>14,.0f} 円")
print(f"累計手数料 : {total_commission:>14,.0f} 円")
print(f"総コスト : {total_slippage + total_commission:>14,.0f} 円")
print("=" * 50)
df_equity["equity"].plot(figsize=(12, 5), title="Equity Curve (with Slippage)")
plt.ylabel("Equity (JPY)")
plt.grid(True)
plt.tight_layout()
plt.show()
# ==============================
# メイン処理
# ==============================
if __name__ == "__main__":
data: pd.DataFrame = fetch_ohlcv(TICKER, START_DATE, END_DATE)
signals: pd.DataFrame = generate_signals(data, SHORT_WINDOW, LONG_WINDOW)
df_trades, df_equity = run_backtest(
signals, INITIAL_CAPITAL, SLIPPAGE_PCT, COMMISSION_PCT, TRADE_UNIT,
)
show_summary(df_trades, df_equity, INITIAL_CAPITAL)
if len(df_trades) > 0:
print("n--- 取引ログ(先頭10件) ---")
print(df_trades.head(10).to_string(index=False))
コードの処理フロー解説
上記のコードは、以下の6ステップで構成されています。
* ステップ1(データ取得):yfinanceで指定銘柄のOHLCVデータをダウンロードし、カラム名を小文字に統一する
* ステップ2(シグナル生成):短期・長期の単純移動平均(SMA)を算出し、ゴールデンクロスで買い、デッドクロスで売りのシグナルを生成する
* ステップ3(スリッページ適用):apply_slippage関数が終値に対して買いなら上乗せ、売りなら下乗せした約定価格を返す
* ステップ4(手数料計算):calc_commission関数が約定代金に手数料率を掛けてコストを算出する
* ステップ5(バックテスト実行):日次ループ内でポジション管理・損益計算を行い、取引ログと資産曲線を記録する
* ステップ6(結果表示):最終資産・累計スリッページ・累計手数料をコンソールに出力し、資産曲線をグラフ描画する
シグナル生成の関数を差し替えれば、RSIやMACDなど任意の戦略にそのまま適用できます。
スリッページ率の設定指針と結果の読み解き方
現実的なスリッページ率の目安
スリッページ率は銘柄の流動性やタイムフレームで大きく変わります。以下の表を目安にしてください。
| 対象 | 片道スリッページ率 | 備考 |
|---|---|---|
| 東証プライム大型株 | 0.01%〜0.05% | 板が厚く滑りにくい |
| 東証スタンダード中小型株 | 0.05%〜0.20% | 出来高が少ない日は要注意 |
| FX(メジャー通貨) | 0.01%〜0.03% | スプレッド拡大時は別途考慮 |
| 仮想通貨(BTC/USDT等) | 0.03%〜0.10% | 取引所・時間帯で大きく変動 |
重要なのは「甘く見積もらない」ことです。迷ったら大きめの値を設定してください。0.05%で利益が出る戦略が0.10%で赤字に転落するなら、その戦略は脆弱です。
スリッページ有無で結果を比較する方法
設定エリアのSLIPPAGE_PCTを0.0と0.05の2パターンで実行し、最終資産を比較してください。差額が累計スリッページコストに相当します。
目安として、スリッページを加味したリターンが「スリッページなし」の70%以下に落ち込む戦略は、実運用で期待どおりの利益が出ない危険性が高いです。この比率をコスト耐性(Cost Robustness)と呼び、80%以上を合格ラインとして判断することを推奨します。
【コピペOK】出来高連動スリッページ版への拡張
固定率では捉えきれない「薄い板での大滑り」を再現するために、出来高に応じてスリッページを変動させる関数を用意しました。先ほどのコードのapply_slippage関数を以下に差し替えるだけで動作します。
# ==============================
# 出来高連動スリッページ(差し替え用)
# ==============================
BASE_SLIPPAGE_PCT: float = 0.02 # 最低スリッページ率(%)
VOLUME_IMPACT: float = 0.5 # 出来高インパクト係数
VOLUME_THRESHOLD: int = 1_000_000 # 基準出来高(株)
def apply_slippage_dynamic(
price: float,
side: str,
volume: float,
base_pct: float = BASE_SLIPPAGE_PCT,
impact: float = VOLUME_IMPACT,
threshold: int = VOLUME_THRESHOLD,
) -> float:
'出来高が少ないほどスリッページを大きくする'
ratio: float = max(threshold / max(volume, 1), 1.0)
dynamic_pct: float = base_pct * (ratio ** impact)
if side == "buy":
return price * (1.0 + dynamic_pct / 100.0)
return price * (1.0 - dynamic_pct / 100.0)
コードの処理フロー解説
上記の関数は、以下の3ステップで動作しています。
* ステップ1(出来高比率の算出):基準出来高を当日出来高で割り、流動性の低さを数値化する。出来高が基準以上なら比率は1.0に固定される
* ステップ2(動的スリッページ率の計算):比率をインパクト係数でべき乗し、基底スリッページ率に乗じる。出来高が半分なら滑りが約1.4倍になる設計である
* ステップ3(約定価格への適用):固定版と同じく、買いなら上乗せ・売りなら下乗せして返す
run_backtest関数内のapply_slippage呼び出しをapply_slippage_dynamicに変更し、引数にrow["volume"]を渡すだけで切り替わります。VOLUME_THRESHOLDは対象銘柄の平均出来高の1.5倍程度に設定すると現実に近い挙動になります。
よくあるエラーと対処法
KeyError: 'close' が発生する
yfinanceのバージョンによってはカラム名がClose(先頭大文字)で返ることがあります。コード内で.lower()変換していますが、マルチインデックス解除に失敗すると残ります。
以下を試してください。
* yfinanceを最新版にアップデートする(pip install -U yfinance)
* df.columnsをprintで確認し、カラム名がマルチインデックスになっていないかチェックする
* 手動でdf.columns = ["open","high","low","close","volume"]と上書きする
取引回数が0件になる
シグナルが一度も発火していない状態です。移動平均の期間設定が長すぎるか、データ期間が短すぎることが原因です。
以下を試してください。
* SHORT_WINDOWとLONG_WINDOWを短く設定する(例:5と20)
* START_DATEを3年以上前に設定し、十分なデータ量を確保する
* signals["position"].value_counts()でシグナルの発生状況を確認する
最終資産が初期資金より大幅に少ない(資金不足で約定しない)
TRADE_UNITが大きすぎて1回目の買いで資金が足りない、またはスリッページ率を極端に大きく設定しているケースです。
以下を試してください。
* INITIAL_CAPITALを増やすかTRADE_UNITを100に下げる
* SLIPPAGE_PCTが1%以上になっていないか確認する(通常は0.01%〜0.20%の範囲)
* 取引ログのexec_priceとpriceの差額を確認し、想定どおりの滑りかチェックする
まとめ
この記事では、Pythonバックテストにスリッページと手数料を組み込む具体的な方法を解説しました。
要点を整理します。
* スリッページは「注文価格と約定価格のズレ」であり、成行注文では必ず発生する隠れたコストである
* 固定スリッページ版はSLIPPAGE_PCTを変えるだけで感度分析ができ、戦略のコスト耐性を数値化できる
* 出来高連動スリッページ版は中小型株や流動性の低い時間帯のリアルな約定を再現する
* コスト耐性(スリッページあり÷なしのリターン比)は80%以上を合格ラインとする
* スリッページ率に迷ったら大きめの値を設定し「辛口テスト」に合格する戦略だけを運用候補にする
次のステップとして、スリッページ率を0.01%刻みで複数パターン実行し、リターンの変化をグラフ化する「スリッページ感度分析」に取り組んでください。コスト耐性が急落する閾値が見つかれば、その戦略の限界点を定量的に把握できます。
さらに、本記事の関数をbacktraderやZiplineなどのフレームワークのカスタムスリッページモデルとして移植すれば、より大規模なポートフォリオバックテストにも対応できます。
