バックテストが嘘をつく理由──手数料・スリッページをPythonで組み込む方法

「バックテストでは年利30%出てたのに、実際にやったら3ヶ月でマイナス10%」──これ、完全に僕の体験談です。原因を調べたら手数料とスリッページを全然考慮してなかった。恥ずかしいけど、同じ過ちを繰り返してほしくないので書く。。。

なぜ僕がこれをやらかしたか

子供が生まれてから「チャートを見る暇がない→自動化するしかない」とPythonを始めた僕が最初に作ったのは、移動平均クロスの超シンプルなバックテストだった。コードを書いて走らせたら「年利35%!」と表示されて興奮した。

でも実際に運用してみると結果がまったく違う。なんでだ?と深掘りしたら、バックテストが「手数料ゼロ・約定は必ず終値ぴったり」という夢の世界で計算していたことがわかった。現実の取引では、約定した瞬間に手数料が引かれるし、大きな注文を出せば出すほど「思ってた値段で買えない(スリッページ)」が発生する。製造メーカーの中型株とかは特に流動性が低くてスリッページが痛い。

手数料とスリッページとは

手数料(Commission)は取引コスト。SBI証券などのアクティブプランでは現物・信用ともに1日100万円まで無料のケースもあるが、取引頻度が高いアルゴ戦略では積み上がる。バックテストには「1約定あたり〇円」または「取引額の〇%」という形で組み込むのが現実的。

スリッページ(Slippage)は「注文を出した価格」と「実際に約定した価格」のズレ。終値で売買する想定のバックテストでも、実際は翌日の寄り付きで約定することが多い。寄り付きの価格は前日終値から数十〜数百円ズレることがある。特に流動性が低い銘柄や、相場が大きく動いた翌朝は要注意。

「そんな細かいこと…」と思うかもしれないが、1回あたり0.1〜0.3%の誤差でも、年間100回取引すれば10〜30%の差になる。年利35%が実質5%になるケースも全然あり得る。

Pythonでリアルなバックテストを組む

今回は外部ライブラリを使わず、pandasだけでシンプルに実装する。対象銘柄はトヨタ自動車(7203)。yfinanceでデータを取得して、移動平均クロス戦略に手数料とスリッページを組み込む。

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

# ---- パラメータ ----
TICKER = "7203.T"          # トヨタ自動車
SHORT_WINDOW = 25
LONG_WINDOW = 75
COMMISSION_RATE = 0.001    # 片道0.1%(SBI証券スタンダードプランの目安)
SLIPPAGE_RATE = 0.002      # 片道0.2%(寄り付き乖離の想定)
INITIAL_CAPITAL = 1_000_000  # 初期資本100万円

# ---- データ取得 ----
df = yf.download(TICKER, start="2023-01-01", end="2025-12-31", progress=False)
df = df[["Open", "Close"]].copy()
df.columns = ["Open", "Close"]

# ---- 移動平均計算 ----
df["SMA_short"] = df["Close"].rolling(SHORT_WINDOW).mean()
df["SMA_long"] = df["Close"].rolling(LONG_WINDOW).mean()

# ゴールデンクロス/デッドクロスのシグナル
df["Signal"] = 0
df.loc[df["SMA_short"] > df["SMA_long"], "Signal"] = 1
df["Position"] = df["Signal"].diff()  # 1=買い, -1=売り

# ---- バックテスト(翌日寄り付きで約定 + 手数料 + スリッページ) ----
capital = INITIAL_CAPITAL
shares = 0
trade_log = []

for i in range(1, len(df)):
    row = df.iloc[i]
    prev = df.iloc[i - 1]
    date = df.index[i]

    # 約定価格 = 翌日寄り付き(Openを使用)
    exec_price = row["Open"]

    if prev["Position"] == 1 and shares == 0:
        # 買いエントリー
        cost = exec_price * (1 + SLIPPAGE_RATE) * (1 + COMMISSION_RATE)
        shares = int(capital // cost)
        capital -= shares * cost
        trade_log.append({"date": date, "action": "BUY", "price": exec_price, "cost": cost, "shares": shares})

    elif prev["Position"] == -1 and shares > 0:
        # 売りエグジット
        revenue = exec_price * (1 - SLIPPAGE_RATE) * (1 - COMMISSION_RATE)
        capital += shares * revenue
        trade_log.append({"date": date, "action": "SELL", "price": exec_price, "revenue": revenue, "shares": shares})
        shares = 0

# 最終評価(ポジション保有中なら時価で計算)
if shares > 0:
    last_close = df["Close"].iloc[-1]
    capital += shares * last_close * (1 - SLIPPAGE_RATE) * (1 - COMMISSION_RATE)

# ---- 結果表示 ----
total_return = (capital - INITIAL_CAPITAL) / INITIAL_CAPITAL * 100
print(f"最終資産: {capital:,.0f}円")
print(f"総リターン: {total_return:.2f}%")
print(f"取引回数: {len(trade_log)}回")

trade_df = pd.DataFrame(trade_log)
print(trade_df.to_string(index=False))

手数料なしとの比較

同じ戦略を「手数料・スリッページゼロ」で走らせると、結果が大きく変わる。以下は実際に比較した結果の例(期間・銘柄・パラメータによって変わるので参考値として見てほしい)。

# 手数料ゼロバージョン(比較用)
def backtest_no_cost(df, capital=1_000_000):
    shares = 0
    for i in range(1, len(df)):
        prev = df.iloc[i - 1]
        exec_price = df.iloc[i]["Open"]
        if prev["Position"] == 1 and shares == 0:
            shares = int(capital // exec_price)
            capital -= shares * exec_price
        elif prev["Position"] == -1 and shares > 0:
            capital += shares * exec_price
            shares = 0
    if shares > 0:
        capital += shares * df["Close"].iloc[-1]
    return (capital - 1_000_000) / 1_000_000 * 100

no_cost_return = backtest_no_cost(df)
print(f"手数料なし: {no_cost_return:.2f}%")
print(f"手数料あり: {total_return:.2f}%")
print(f"コストの影響: {no_cost_return - total_return:.2f}%ポイント")

実際に走らせると、取引頻度が多い戦略ほどコストの影響が大きくなる。月10回以上売買する高頻度戦略は特に注意が必要で、手数料だけで年利10〜20%相当を食われることもある。

現実に近づけるためのコツ

いくつか追加で意識するといいポイントをまとめる。

① スリッページは固定値より確率的にモデル化する:本来スリッページは毎回同じではなく、ボラティリティに比例して大きくなる。ATR(Average True Range)の一定割合をスリッページとして加える方法もある。

② 約定は終値ではなく翌日の寄り付きを使う:今回のコードでもそう実装したが、終値でシグナルが出て終値で約定というのは現実にはほぼ不可能(特に引け後のシグナル計算後では)。Openカラムを使うのがより現実的。

③ 税金も考慮する:バックテストでは税引き前リターンで比較することが多いが、実運用では利益の約20%が課税される。長期戦略の場合は特定口座を使えばほぼ自動で処理されるが、意識しておくと計画が立てやすい。

まとめ

バックテストは「理想の世界」の話。手数料とスリッページを抜きにした結果は、現実とかけ離れた数字になりやすい。特にトヨタや日立のような流動性の高い大型株より、中型の製造メーカー銘柄を触るなら余計にスリッページの影響は大きい。

今回のコードをベースに、自分がよく触る銘柄で一度「コストありvsコストなし」を比較してみてほしい。思ってたより差が出るはずで、戦略を見直すきっかけになる。個人的には次にこのコードをATRベースの動的スリッページに拡張してみようと思っている。手数料の計算式も証券会社ごとに違うから、自分の口座に合わせてカスタマイズしてみてほしい。

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