勝率・期待値を数値で見る!Pythonでプロフィットファクターを計算する方法

自動化・運用

※本記事のコードや情報は執筆時点の仕様に基づいています。投資は自己責任であり、必ずデモ環境や少額資金でテストした上で運用してください。

Pythonでテクニカル指標を実装し、売買シグナルを生成できるようになったとしても、それだけでは「本当に儲かる戦略か」を判断できません。

バックテスト(過去データによる検証)を行い、勝率・平均損益・プロフィットファクターといった統計指標を算出して初めて、戦略の優劣を客観的に評価できます。

しかし、多くの個人投資家がバックテストの「コード実装」で挫折しています。

売買ログの生成方法、損益計算のロジック、統計指標の正しい算出式など、つまずくポイントが複数あるためです。

この記事では、yfinanceで取得した実データを使い、RSIベースの売買戦略をバックテストし、勝率・平均利益・平均損失・プロフィットファクター・最大ドローダウンまでを一気に算出するコードを提供します。

すべてコピペでそのまま動作する実装に限定していますので、自分の戦略に差し替えて応用してください。

バックテストで算出すべき5つの重要指標

戦略を評価するための統計指標は数多くありますが、まずは以下の5つを押さえれば十分です。

ここでは各指標の定義と「何を意味するのか」を明確にします。

基本の3指標:勝率・平均利益・平均損失

最も基本的な評価軸は、勝ちトレードと負けトレードの統計です。

  • 勝率(Win Rate):全トレードのうち利益が出たトレードの割合です
  • 平均利益(Avg Win):勝ちトレード1回あたりの平均利益額です
  • 平均損失(Avg Loss):負けトレード1回あたりの平均損失額です

勝率が高くても、1回の負けで大きく損失を出す戦略は危険です。

逆に、勝率が低くても「損小利大」を徹底していれば、トータルでプラスになることがあります。

勝率だけで戦略を評価するのは危険です。必ず「勝率 × 平均利益」と「敗率 × 平均損失」のバランスで判断してください。

プロフィットファクター(Profit Factor)

プロフィットファクター(PF)は、戦略の収益力を1つの数値で表す最重要指標です。


プロフィットファクター = 総利益 ÷ 総損失(絶対値)
PFの値評価
2.0以上非常に優秀な戦略
1.5〜2.0実運用に耐えうる水準
1.0〜1.5手数料・スリッページを考慮すると微妙
1.0未満損失が利益を上回っている(赤字戦略)

PFが1.0を下回る戦略は、取引回数を増やすほど資金が減っていくことを意味します。

最低でも1.5以上を目安として戦略を設計してください。

最大ドローダウン(Max Drawdown)

最大ドローダウンは、資産曲線のピークからの最大下落幅を示す指標です。

「最悪の場合、どこまで資産が減る可能性があるか」を定量化できます。


最大ドローダウン = (トラフ値 - ピーク値) / ピーク値 × 100(%)
  • ドローダウンが20%を超える戦略は、精神的に継続が困難になります
  • プロフィットファクターが高くても、最大ドローダウンが大きい戦略は実運用に向きません
  • 「PFとドローダウンのバランス」が戦略評価の最終判断基準となります

【コピペOK】RSI逆張り戦略のバックテストコード

ここからは実際のコードを提示します。

RSI(14日)が30以下で買い、70以上で売るというシンプルな逆張り戦略をバックテストし、全5指標を算出します。

事前準備:ライブラリのインストール


pip install yfinance pandas matplotlib

【コピペOK】バックテスト+統計指標算出コード


import yfinance as yf
import pandas as pd
import matplotlib.pyplot as plt

# ==============================
# 設定エリア
# ==============================
SYMBOL = "7203.T"        # トヨタ自動車
PERIOD = "5y"            # 検証期間(5年間)
RSI_WINDOW = 14          # RSI計算期間
RSI_BUY_THRESHOLD = 30   # 買いシグナル閾値
RSI_SELL_THRESHOLD = 70  # 売りシグナル閾値
INITIAL_CAPITAL = 1_000_000  # 初期資金(100万円)

# ==============================
# データ取得
# ==============================
def fetch_data(symbol: str, period: str) -> pd.DataFrame:
    ticker = yf.Ticker(symbol)
    df = ticker.history(period=period)
    if df.empty:
        raise ValueError(f"{symbol} のデータを取得できませんでした。")
    return df

# ==============================
# RSI計算(Wilder方式)
# ==============================
def calc_rsi(series: pd.Series, window: int = 14) -> pd.Series:
    delta = series.diff()
    gain = delta.where(delta > 0, 0.0)
    loss = (-delta).where(delta < 0, 0.0)
    avg_gain = gain.ewm(alpha=1/window, min_periods=window, adjust=False).mean()
    avg_loss = loss.ewm(alpha=1/window, min_periods=window, adjust=False).mean()
    rs = avg_gain / avg_loss
    return 100 - (100 / (1 + rs))

# ==============================
# 売買シグナル生成
# ==============================
def generate_signals(df: pd.DataFrame) -> pd.DataFrame:
    ""RSIに基づく売買シグナルを生成します。""
    df = df.copy()
    df["RSI"] = calc_rsi(df["Close"], RSI_WINDOW)

    # シグナル列の初期化
    df["Signal"] = 0  # 0=何もしない, 1=買い, -1=売り

    position = 0  # 0=ノーポジ, 1=保有中

    for i in range(1, len(df)):
        rsi_val = df["RSI"].iloc[i]

        if pd.isna(rsi_val):
            continue

        # 買いシグナル:RSIが閾値以下 かつ ノーポジ
        if rsi_val <= RSI_BUY_THRESHOLD and position == 0:
            df.iloc[i, df.columns.get_loc("Signal")] = 1
            position = 1

        # 売りシグナル:RSIが閾値以上 かつ 保有中
        elif rsi_val >= RSI_SELL_THRESHOLD and position == 1:
            df.iloc[i, df.columns.get_loc("Signal")] = -1
            position = 0

    return df

# ==============================
# トレードログ生成
# ==============================
def create_trade_log(df: pd.DataFrame) -> pd.DataFrame:
    ""売買シグナルからトレードログ(エントリー・イグジットのペア)を生成します。""
    trades = []
    entry_date = None
    entry_price = None

    for i in range(len(df)):
        signal = df["Signal"].iloc[i]
        price = df["Close"].iloc[i]
        date = df.index[i]

        if signal == 1:
            entry_date = date
            entry_price = price

        elif signal == -1 and entry_price is not None:
            exit_date = date
            exit_price = price
            profit = exit_price - entry_price
            profit_pct = (profit / entry_price) * 100

            trades.append({
                "エントリー日": entry_date,
                "エントリー価格": round(entry_price, 1),
                "イグジット日": exit_date,
                "イグジット価格": round(exit_price, 1),
                "損益(円)": round(profit, 1),
                "損益率(%)": round(profit_pct, 2),
            })
            entry_price = None

    return pd.DataFrame(trades)

# ==============================
# 統計指標の算出
# ==============================
def calc_statistics(trade_log: pd.DataFrame) -> dict:
    ""トレードログから5つの重要指標を算出します。""
    if trade_log.empty:
        print("⚠ トレードが1件も発生しませんでした。")
        return {}

    profits = trade_log["損益(円)"]
    wins = profits[profits > 0]
    losses = profits[profits <= 0]

    total_trades = len(profits)
    win_count = len(wins)
    loss_count = len(losses)

    win_rate = (win_count / total_trades) * 100 if total_trades > 0 else 0
    avg_win = wins.mean() if len(wins) > 0 else 0
    avg_loss = losses.mean() if len(losses) > 0 else 0

    total_profit = wins.sum() if len(wins) > 0 else 0
    total_loss = abs(losses.sum()) if len(losses) > 0 else 0
    profit_factor = total_profit / total_loss if total_loss > 0 else float("inf")

    # 最大ドローダウンの計算
    cumulative = profits.cumsum()
    peak = cumulative.cummax()
    drawdown = cumulative - peak
    max_drawdown = drawdown.min()

    stats = {
        "総トレード数": total_trades,
        "勝ちトレード数": win_count,
        "負けトレード数": loss_count,
        "勝率(%)": round(win_rate, 2),
        "平均利益(円)": round(avg_win, 1),
        "平均損失(円)": round(avg_loss, 1),
        "総利益(円)": round(total_profit, 1),
        "総損失(円)": round(-total_loss, 1),
        "純損益(円)": round(total_profit - total_loss, 1),
        "プロフィットファクター": round(profit_factor, 3),
        "最大ドローダウン(円)": round(max_drawdown, 1),
    }
    return stats

# ==============================
# 資産曲線の描画+保存
# ==============================
def plot_equity_curve(trade_log: pd.DataFrame, initial_capital: int):
    ""累積損益から資産曲線を描画して保存します。""
    if trade_log.empty:
        return

    equity = initial_capital + trade_log["損益(円)"].cumsum()
    equity = pd.concat([pd.Series([initial_capital]), equity]).reset_index(drop=True)

    fig, ax = plt.subplots(figsize=(12, 6))
    ax.plot(equity, color="blue", linewidth=1.5, label="Equity Curve")
    ax.axhline(initial_capital, color="gray", linestyle="--", alpha=0.5, label="Initial Capital")
    ax.set_title(f"{SYMBOL} - RSI Reversal Strategy Equity Curve", fontsize=13)
    ax.set_ylabel("Capital (JPY)")
    ax.set_xlabel("Trade Number")
    ax.legend()
    ax.grid(True, alpha=0.3)

    fig.savefig("equity_curve.png", dpi=150, bbox_inches="tight", facecolor="white")
    plt.close(fig)
    print("✔ 資産曲線を保存しました: equity_curve.png")

# ==============================
# メイン処理
# ==============================
if __name__ == "__main__":
    # 1. データ取得
    print(f"=== {SYMBOL} のバックテストを開始 ===n")
    df = fetch_data(SYMBOL, PERIOD)

    # 2. シグナル生成
    df = generate_signals(df)

    # 3. トレードログ生成
    trade_log = create_trade_log(df)
    print("--- トレードログ ---")
    print(trade_log.to_string(index=False))

    # 4. 統計指標算出
    stats = calc_statistics(trade_log)
    print("n--- 統計指標 ---")
    for key, value in stats.items():
        print(f"  {key}: {value}")

    # 5. 資産曲線描画
    plot_equity_curve(trade_log, INITIAL_CAPITAL)

コードの処理フロー解説

上記のコードは、以下の5ステップで構成されています。

  1. データ取得:yfinanceで5年分の日足データを取得します
  2. シグナル生成:RSIが30以下で買い、70以上で売りのフラグを立てます
  3. トレードログ生成:買い→売りのペアをDataFrameとしてまとめます
  4. 統計指標算出:勝率・PF・最大ドローダウンなど5指標を計算します
  5. 資産曲線描画:累積損益をグラフ化してPNG画像として保存します

シグナル生成の generate_signals() 関数を差し替えれば、MACD戦略やゴールデンクロス戦略など、任意のアルゴリズムに対応できます。

統計指標の読み方と戦略改善のヒント

数値を算出しても、その読み方を間違えると誤った判断につながります。

ここでは、各指標の「実践的な読み解き方」を解説します。

プロフィットファクターの落とし穴

PFが2.0以上あっても、以下のケースでは過信してはいけません。

  • トレード回数が極端に少ない:5〜10回のトレードでPF=3.0が出ても統計的に無意味です。最低30回以上のトレードが必要です
  • 特定期間だけ好成績:相場環境に依存した「たまたまの好成績」かもしれません。異なる期間でも検証してください
  • 手数料・スリッページ未考慮:実際の取引では売買手数料と約定価格のズレが発生します

バックテスト結果は「理想的な上限値」であり、実運用では成績が20〜30%程度悪化するのが一般的です。PF=1.5の戦略は、実運用ではPF=1.1〜1.2程度に落ちる可能性があると想定してください。

勝率と損益比率(リスクリワード比)のバランス

勝率と平均利益・平均損失の関係は、以下の表のように整理できます。

戦略タイプ勝率目安損益比率(平均利益÷平均損失)特徴
高勝率型60〜80%0.5〜1.0コツコツ勝つが、1回の負けが大きい
バランス型40〜60%1.0〜2.0安定した成績を維持しやすい
損小利大型20〜40%2.0〜5.0負けが多いが、1回の勝ちで大きく取る

どのタイプが正解ということはなく、自分の性格やリスク許容度に合った戦略を選択することが重要です。

高勝率型は精神的に楽ですが、連敗時の1回の大負けで利益が吹き飛ぶリスクがあります。

最大ドローダウンの許容基準

最大ドローダウンの許容水準は、運用資金の性質によって異なります。

  • 生活資金を兼ねる場合:最大ドローダウン10%以内が目安です
  • 余裕資金の場合:20%程度まで許容可能です
  • 少額の実験資金の場合:30%以上でも学習目的なら許容範囲です

最大ドローダウンが許容範囲を超える戦略は、ポジションサイズを縮小するか、損切りルールを追加して改善してください。

【コピペOK】手数料を考慮した現実的なシミュレーションコード

実運用では売買手数料が発生します。

先ほどのコードに手数料を組み込んだ、より現実的なバージョンを提供します。

【コピペOK】手数料込みの損益計算関数


# ==============================
# 手数料込みトレードログ生成
# ==============================
def create_trade_log_with_fee(
    df: pd.DataFrame,
    commission_rate: float = 0.001,  # 片道0.1%(往復0.2%)
) -> pd.DataFrame:
    ""手数料を考慮したトレードログを生成します。""
    trades = []
    entry_date = None
    entry_price = None

    for i in range(len(df)):
        signal = df["Signal"].iloc[i]
        price = df["Close"].iloc[i]
        date = df.index[i]

        if signal == 1:
            entry_date = date
            entry_price = price

        elif signal == -1 and entry_price is not None:
            exit_date = date
            exit_price = price

            # 手数料の計算(買い時+売り時)
            buy_commission = entry_price * commission_rate
            sell_commission = exit_price * commission_rate
            total_commission = buy_commission + sell_commission

            # 手数料控除後の損益
            gross_profit = exit_price - entry_price
            net_profit = gross_profit - total_commission
            net_profit_pct = (net_profit / entry_price) * 100

            trades.append({
                "エントリー日": entry_date,
                "エントリー価格": round(entry_price, 1),
                "イグジット日": exit_date,
                "イグジット価格": round(exit_price, 1),
                "手数料合計(円)": round(total_commission, 1),
                "粗利益(円)": round(gross_profit, 1),
                "純損益(円)": round(net_profit, 1),
                "純損益率(%)": round(net_profit_pct, 2),
            })
            entry_price = None

    return pd.DataFrame(trades)

この関数では、commission_rate=0.001(片道0.1%)をデフォルトに設定しています。

SBI証券のアクティブプランでは約定金額に応じて手数料が変動しますが、概算として0.1%は妥当な値です。

手数料込みの統計指標を算出する場合は、トレードログの "純損益(円)" 列を使って calc_statistics() を呼び出してください。


# 手数料込みで統計指標を算出する例
trade_log_fee = create_trade_log_with_fee(df, commission_rate=0.001)
trade_log_fee = trade_log_fee.rename(columns={"純損益(円)": "損益(円)"})
stats_with_fee = calc_statistics(trade_log_fee)

よくあるエラーと対処法

バックテスト実装で初心者がつまずきやすいポイントを整理します。

トレードが1件も発生しません

RSIが30以下や70以上に到達しないほど穏やかな値動きの期間・銘柄では、シグナルが発生しないことがあります。

以下を試してください。

  • 検証期間を延ばすPERIOD = "5y""10y" に変更してください
  • 閾値を緩和するRSI_BUY_THRESHOLD = 35RSI_SELL_THRESHOLD = 65 などに調整してください
  • ボラティリティの高い銘柄に変更する:新興市場の銘柄やETFで検証してみてください

未決済ポジションが残った状態でバックテストが終了します

検証期間の最後にまだ売りシグナルが発生していない場合、そのトレードはトレードログに含まれません。

これは意図的な設計であり、「未確定の損益を含めない」という保守的なアプローチです。

未決済ポジションも含めて評価したい場合は、最終日の終値で強制決済するロジックを追加してください。

プロフィットファクターが「inf」(無限大)になります

負けトレードが1件もない場合、総損失が0になるため、PFの計算結果がinfになります。

これはエラーではなく正常な計算結果ですが、「勝ちしかないバックテスト」は検証期間やトレード回数が不十分である可能性が高いです。

PFがinfまたは極端に高い値(10以上)を示す場合は、過剰最適化(オーバーフィッティング)を疑ってください。異なる銘柄・異なる期間でも同様の成績が出るかクロスバリデーションを実施することを推奨します。

まとめ

この記事では、Pythonで投資戦略のバックテストを実行し、勝率・平均損益・プロフィットファクター・最大ドローダウンなどの統計指標を算出する方法を解説しました。

要点を整理します。

  • プロフィットファクター(PF)は戦略評価の最重要指標であり、最低1.5以上を目安としてください
  • 勝率だけでなく「損益比率(リスクリワード比)」とのバランスが重要です
  • 最大ドローダウンは「最悪のシナリオでどこまで耐えられるか」を事前に把握するための指標です
  • 手数料・スリッページを考慮しないバックテストは楽観的すぎるため、必ず手数料込みで検証してください
  • トレード回数が30回未満の統計指標は信頼性が低い点に注意してください

次のステップとして、RSI以外のテクニカル指標(MACD・ボリンジャーバンド・ゴールデンクロスなど)でも同じフレームワークを使ってバックテストを実行し、戦略間のパフォーマンス比較に挑戦してみてください。

generate_signals() 関数を差し替えるだけで、任意のアルゴリズムを同じ評価基準で比較できます。

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