Pythonで株価バックテストを実装|移動平均クロス戦略

Python実装・コード

※本記事には広告・アフィリエイトリンクが含まれます。収益はサイト運営・検証費用に充てています。掲載コードは学習・検証目的です。

先に結論です。移動平均クロスのバックテストは50行で書けますが、その50行のうち3行を間違えると、成績が数倍に化けます。間違えるのは、シグナルをずらす行、コストを引く行、そして評価する行です。

ネット上のサンプルコードの多くは、シグナルを1日ずらす処理までは入っています。しかしコストは入っていないか、入っていても毎日引いていて、実質的に意味が変わっています。さらに、Buy&Holdと比べるだけで終わり、取引回数もシャープレシオも出しません。

この記事では、そのまま使い回せるバックテスト関数を1つ作ります。コストは売買が発生した日にだけ適用し、評価指標は7種類まとめて返します。最後に、パラメータ最適化で自分を騙さない方法まで扱います。

📘 外部参考rolling(pandas公式)yfinance(GitHub)

戦略ルールと前提

  • ゴールデンクロス(短期MAが長期MAを上抜け)→ 翌営業日の寄り付きで買い
  • デッドクロス(短期MAが長期MAを下抜け)→ 翌営業日の寄り付きで手仕舞い
  • ポジションはフルインベストかキャッシュ100%の2択。空売りなし
  • 初期設定は短期5日・長期25日

「翌営業日」と明記しているのが重要です。当日の終値でクロスを判定した瞬間には、もう当日の取引は終わっています。この当たり前を、コードで表現できるかどうかが分かれ目です。

データを整える

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

def load_ohlc(ticker: str, period: str = "5y") -> pd.DataFrame:
    df = yf.download(ticker, period=period, auto_adjust=True, progress=False)
    if isinstance(df.columns, pd.MultiIndex):
        df.columns = df.columns.get_level_values(0)
    df = df[["Open", "High", "Low", "Close"]].dropna()
    df.index = pd.to_datetime(df.index).tz_localize(None)
    return df.sort_index()

px = load_ohlc("7203.T")
print(px.tail(3), len(px))

auto_adjust=Trueは必須です。未調整の価格で長期のバックテストを回すと、株式分割の日に-50%の暴落が発生したことになり、そこでデッドクロスが発生します。存在しない事象で売買しているので、結果は全部無意味になります。

バックテスト本体

ここが記事の中心です。1つの関数にまとめ、コストを引数で受け取ります。

def backtest_ma_cross(
    px: pd.DataFrame,
    fast: int = 5,
    slow: int = 25,
    fee_one_way: float = 0.0005,   # 片道0.05%(手数料+スリッページの合算想定)
) -> pd.DataFrame:
    df = px.copy()
    df["ma_fast"] = df["Close"].rolling(fast).mean()
    df["ma_slow"] = df["Close"].rolling(slow).mean()

    # 当日終値時点で判定できるシグナル(0 or 1)
    df["raw_signal"] = (df["ma_fast"] > df["ma_slow"]).astype(float)
    df.loc[df["ma_slow"].isna(), "raw_signal"] = np.nan

    # 実際に建てられるのは翌営業日から
    df["position"] = df["raw_signal"].shift(1)

    df["market_return"] = df["Close"].pct_change()
    df["gross"] = df["market_return"] * df["position"]

    # 売買が発生した日だけコストを引く
    df["trade"] = df["position"].diff().abs().fillna(0)
    df["cost"] = df["trade"] * fee_one_way
    df["net"] = df["gross"] - df["cost"]

    df["equity"] = (1 + df["net"].fillna(0)).cumprod()
    df["buy_hold"] = (1 + df["market_return"].fillna(0)).cumprod()
    return df

result = backtest_ma_cross(px)
print(result[["position", "trade", "net", "equity", "buy_hold"]].tail())

コスト計算のposition.diff().abs()がポイントです。ポジションが0から1に変わった日、1から0に変わった日にだけ1が立ちます。毎日feeを引いているコードを見かけますが、それはポジションを持ち続けているだけの日にも課金していることになり、長期保有型の戦略を不当に不利にします。

間違い症状正しい書き方
signalをshiftしない成績が異常に良いraw_signal.shift(1)
shiftを2回かける反応が鈍く成績が悪いshiftは1回だけ
コストを毎日引く保有日数が長いほど不利position.diff().abs()に掛ける
コストを片道で1回だけ往復分が半額になるdiffは往復それぞれで1が立つ
dropnaを先にかけるウォームアップ期間がズレるNaNのまま計算しfillna(0)

評価指標をまとめて出す

累積リターンだけ見ても判断できません。年率、シャープレシオ、最大ドローダウン、取引回数、勝率を一度に出します。

TRADING_DAYS = 245   # 日本株の概算営業日数

def evaluate(df: pd.DataFrame) -> dict:
    net = df["net"].dropna()
    equity = df["equity"].dropna()
    if len(equity) < 2:
        return {}

    years = len(net) / TRADING_DAYS
    total = float(equity.iloc[-1] - 1)
    cagr = float(equity.iloc[-1] ** (1 / years) - 1) if years > 0 else np.nan
    vol = float(net.std(ddof=1) * np.sqrt(TRADING_DAYS))
    sharpe = float(net.mean() / net.std(ddof=1) * np.sqrt(TRADING_DAYS)) if net.std(ddof=1) else np.nan

    dd = equity / equity.cummax() - 1
    trades = int(df["trade"].sum())

    # 保有日だけのリターンで勝率を見る
    held = net[df["position"].reindex(net.index) == 1]
    win_rate = float((held > 0).mean()) if len(held) else np.nan

    bh = df["buy_hold"].dropna()
    return {
        "total_return": round(total, 4),
        "cagr": round(cagr, 4),
        "sharpe": round(sharpe, 3),
        "max_dd": round(float(dd.min()), 4),
        "trades": trades,
        "win_rate": round(win_rate, 3),
        "days_in_market": round(float((df["position"] == 1).mean()), 3),
        "buy_hold_return": round(float(bh.iloc[-1] - 1), 4),
    }

print(evaluate(result))

days_in_marketを入れているのに理由があります。この戦略は下落局面でキャッシュに逃げるので、市場に居る時間がBuy&Holdより短くなります。リターンが半分でも、市場に居た時間が半分なら、単位リスクあたりでは同等です。総リターンだけを比べると、この点を見落とします。

コストを変えて感応度を見る

def cost_sensitivity(px: pd.DataFrame, fees=(0.0, 0.0005, 0.001, 0.002, 0.005)) -> pd.DataFrame:
    rows = []
    for f in fees:
        r = evaluate(backtest_ma_cross(px, fee_one_way=f))
        r["fee_one_way"] = f
        rows.append(r)
    return pd.DataFrame(rows).set_index("fee_one_way")

print(cost_sensitivity(px))

手数料無料のプランを提供する証券会社は増えましたが、スリッページはゼロになりません。寄り付きの成行は想定価格から滑ります。ここでは手数料とスリッページをまとめてfee_one_wayとして扱い、0.05%〜0.2%あたりで様子を見るのが実務的です。

この表で成績が急に崩れるなら、その戦略は取引回数に依存しています。年間の取引が数十回を超える戦略ほど、コストの前提が結論を左右します。

パラメータ最適化で自分を騙さない

「5日/25日じゃなくて、もっと良い組み合わせがあるのでは」と考えるのは自然です。総当たりで探せます。ただし、ここが最も危険な場所です。

def grid_search(px: pd.DataFrame, fasts=range(3, 26, 2), slows=range(20, 101, 5)) -> pd.DataFrame:
    rows = []
    for f in fasts:
        for s in slows:
            if f >= s:
                continue
            m = evaluate(backtest_ma_cross(px, fast=f, slow=s))
            if not m:
                continue
            rows.append({"fast": f, "slow": s, **m})
    return pd.DataFrame(rows)

grid = grid_search(px)
print(grid.sort_values("sharpe", ascending=False).head(10))

上位10件を見ると、シャープレシオが最も高い組み合わせが見つかります。しかし、その1点を採用してはいけません。確認すべきは、その周辺のパラメータでも成績が良いかどうかです。

def robustness(grid: pd.DataFrame, metric: str = "sharpe") -> pd.DataFrame:
    pivot = grid.pivot(index="fast", columns="slow", values=metric)
    best = grid.loc[grid[metric].idxmax()]
    f, s = int(best["fast"]), int(best["slow"])

    neighbors = grid[
        (grid["fast"].between(f - 4, f + 4)) & (grid["slow"].between(s - 10, s + 10))
    ]
    print(f"best: fast={f} slow={s} {metric}={best[metric]:.3f}")
    print(f"周辺{len(neighbors)}点の平均: {neighbors[metric].mean():.3f}")
    print(f"周辺の最小: {neighbors[metric].min():.3f}")
    return pivot.round(2)

print(robustness(grid))

最良点が3.0で、周辺の平均が0.2なら、それは偶然掘り当てた穴です。実運用でそのパラメータを使っても再現しません。逆に、最良点が1.2で周辺平均も1.0なら、その領域には何かがあるかもしれない。点ではなく面で見るのが、オーバーフィットを避ける最低限の作法です。

期間を分けて確認する

def split_test(px: pd.DataFrame, fast: int, slow: int, ratio: float = 0.6) -> pd.DataFrame:
    k = int(len(px) * ratio)
    rows = []
    for name, sub in [("in_sample", px.iloc[:k]), ("out_of_sample", px.iloc[k:])]:
        m = evaluate(backtest_ma_cross(sub, fast=fast, slow=slow))
        m["period"] = name
        rows.append(m)
    return pd.DataFrame(rows).set_index("period")

print(split_test(px, fast=5, slow=25))

前半で選んだパラメータが後半でも通用するか。ここで崩れるなら、最適化した意味はありません。僕が試した範囲では、トヨタの5日/25日は前半後半でシャープレシオが大きく変わり、安定しているとは言えませんでした。

結果をどう受け止めるか

移動平均クロスがBuy&Holdに勝てないのは、珍しいことではありません。トレンドが明確な局面では機能しますが、レンジ相場では往復ビンタでコストだけが積み上がります。tradesdays_in_marketを見れば、何が起きたかは説明できます。

この結果を「戦略がダメだった」で終わらせず、次の材料にします。取引回数が多すぎるならフィルタ(トレンドの強さ、出来高)を足す。ドローダウンが深いならポジションサイズを下げる。後者の考え方は最大ドローダウンの記事にまとめています。

また、書いたコードにlookaheadが残っていないかは、機械的に検査できます。バックテストのバグをpytestで潰す記事に、交互データで検出するテストを載せました。

まとめ

バックテストで大事なのは、良い成績を出すことではなく、出た成績を信じてよいかを判定できることです。今回のコードで押さえたのは4点でした。

シグナルはshift(1)で1日だけずらす。コストはposition.diff().abs()が立った日にだけ引く。評価は総リターンではなく、シャープレシオ・最大ドローダウン・取引回数・市場滞在率まで見る。最適化は点ではなく周辺の面で判断する。

ライブラリに任せたい場合はBacktesting.py入門、指標や統計の基礎を固めたい場合は株分析を学ぶ本3選を参照してください。


【投資免責事項】本記事で紹介するコードおよび分析結果は情報提供を目的としたものであり、特定の銘柄への投資を推奨するものではありません。投資判断はご自身の責任で行ってください。

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