「勝率60%だから勝てる」は本当か? p値を知ってバックテストの見方が変わった話

基礎知識・戦略

バックテストで勝率60%という結果が出たとき、僕は素直に喜びました。半分より1割も多く勝てるなら十分やっていける、と。ところが実運用に移すと成績はまったく振るわず、しばらくして気づきました。60勝40敗という結果は、コイン投げでも普通に起こりうるのだと。

100回試して60回勝つ確率は、完全にランダムな売買でも約2.8%あります。決して天文学的な数字ではありません。いくつも戦略を試していれば、そのうち1つくらいは「勝率60%」を叩き出す——これが偶然の正体でした。

この記事では、その「偶然かどうか」を判定するためのp値をPythonで計算します。自分のバックテスト結果が実力なのか運なのかを切り分けるための、実務的な検定手法をまとめました。

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

p値は「偶然でもこうなる確率」

p値の定義は少しややこしいのですが、投資の文脈に落とすと分かりやすくなります。

「その戦略に何の優位性もなかったと仮定したとき、たまたま今回の結果以上に良い成績が出る確率」——これがp値です。

p値意味バックテストでの解釈
0.5半々まったく偶然の範囲
0.205回に1回偶然でも普通に起こる
0.0520回に1回一応の目安ライン
0.01100回に1回偶然とは考えにくい
0.0011000回に1回かなり強い証拠

重要なのは、p値が小さいほど「偶然では説明しにくい」ことを示すだけで、「儲かる」とは何も言っていない点です。ここを取り違えると、統計的に有意だが経済的には無意味な戦略を掴むことになります。

もう1つ、よくある誤解を先に潰しておきます。「p値0.03だから、この戦略が正しい確率が97%」——これは間違いです。p値が答えているのは「優位性がゼロだと仮定したら、この結果が出る確率」であって、その逆ではありません。

この違いは、たとえるなら次のようなものです。

  • p値が答えていること: 「イカサマのないサイコロなら、6が5回続く確率は0.01%」
  • p値が答えていないこと: 「このサイコロがイカサマである確率は99.99%」

後者を知るには、そもそも「イカサマのサイコロがどれくらい世の中にあるか」という事前の情報が必要になります。優位性のある戦略がそもそも希少なら、p値0.03程度では「見つけた」とは到底言えません。相場に転がっている戦略の大半は無効だと考えるなら、基準はもっと厳しくすべきです。

📘 外部参考P-Value(Investopedia)P値(Wikipedia)

勝率60%は偶然で起こりうるのか

まず、冒頭の疑問を直接計算してみます。二項検定を使えば一発で出ます。

pip install scipy numpy pandas yfinance matplotlib
from scipy.stats import binomtest

def win_rate_test(wins, total, expected=0.5):
    """勝率が偶然(コイン投げ)で説明できるかを検定する"""
    result = binomtest(wins, total, expected, alternative="greater")
    return float(result.pvalue)


print(f"{'試行数':>7s} {'勝ち':>6s} {'勝率':>7s} {'p値':>9s}  判定")
for total in (20, 50, 100, 200, 500, 1000):
    wins = int(total * 0.6)
    p = win_rate_test(wins, total)
    verdict = "偶然とは言い切れない" if p < 0.05 else "偶然の範囲内"
    print(f"{total:>7d} {wins:>6d} {wins / total:>6.1%} {p:>9.4f}  {verdict}")

実行すると、次のことが分かります。同じ勝率60%でも、試行数によって意味がまるで違うのです。

  • 20回で12勝: p値は0.25程度。まったく当てにならない
  • 100回で60勝: p値は0.028。ぎりぎり基準を満たす程度
  • 500回で300勝: p値は極めて小さい。偶然では説明しにくい

僕が喜んでいたのは、実は40回程度のトレード結果でした。この試行数では、勝率70%でも偶然の範囲に入ってしまいます。「サンプルが少なすぎた」というだけの話だったわけです。

では何回試せばよいのか

逆算してみます。「勝率◯%を主張するには最低何回のトレードが必要か」を求めるコードです。

def required_trades(win_rate, alpha=0.05, max_n=3000):
    """その勝率を統計的に主張するのに必要な最小試行数"""
    for n in range(10, max_n):
        wins = round(n * win_rate)
        if binomtest(wins, n, 0.5, alternative="greater").pvalue < alpha:
            return n
    return None


print("勝率ごとに必要なトレード数(有意水準5%)")
for wr in (0.52, 0.55, 0.60, 0.65, 0.70):
    n = required_trades(wr)
    print(f"  勝率 {wr:.0%} → 最低 {n:>4d} 回")

この結果はかなり衝撃的でした。勝率52%という現実的な優位性を証明するには、1,000回以上のトレードが必要になります。スイングトレードで年間50回売買する人なら、20年分のデータが要る計算です。

つまり個人が数年のバックテストで「この戦略は優位性がある」と統計的に証明するのは、そもそもかなり難しいということです。この事実を知ってから、僕は自分の検証結果に対してずいぶん慎重になりました。

勝率ではなく損益で検定する

そもそも勝率だけを見るのは片手落ちです。勝率40%でも、勝ちが大きければ十分に儲かります。実際に検定すべきなのは「平均損益がプラスと言えるか」です。

ここでは1標本のt検定を使います。「平均損益はゼロである」という仮説を立て、それを棄却できるかを見ます。

import numpy as np
import pandas as pd
from scipy import stats


def profit_test(pnl):
    """トレード損益列に対して、平均がプラスと言えるかを検定する"""
    x = pd.Series(pnl).dropna()
    t_stat, p_two = stats.ttest_1samp(x, 0.0)
    p_one = p_two / 2 if t_stat > 0 else 1 - p_two / 2   # 片側検定に直す

    print(f"トレード数   : {len(x):>10,d}")
    print(f"平均損益     : {x.mean():>10,.1f}")
    print(f"標準偏差     : {x.std():>10,.1f}")
    print(f"勝率         : {(x > 0).mean():>10.1%}")
    print(f"t統計量      : {float(t_stat):>10.3f}")
    print(f"p値(片側)  : {float(p_one):>10.4f}")

    if p_one < 0.01:
        print("→ 偶然とは考えにくい水準です")
    elif p_one < 0.05:
        print("→ 一応の基準は満たしますが、過信は禁物です")
    else:
        print("→ 偶然の範囲内。優位性があるとは言えません")
    return float(p_one)


# 例: 勝率55%、勝ち平均+1.2%、負け平均-1.0% を150回
rng = np.random.default_rng(42)
sample = np.where(rng.random(150) < 0.55,
                  rng.normal(1.2, 0.8, 150),
                  rng.normal(-1.0, 0.7, 150))
profit_test(sample)

t検定には注意点があります。トレード損益の分布は正規分布ではない——特に裾が厚いため、t検定の前提が厳密には成り立ちません。目安としては使えますが、鵜呑みにはできません。

この「株価データは正規分布に従わない」という問題は、歪度・尖度で「暴落しやすさ」を数値化する方法で詳しく扱っています。

分布を仮定しないブートストラップ検定

正規分布を仮定できないなら、仮定しない方法を使えばよいわけです。ブートストラップ法は、手元のデータを何度もシャッフルして「偶然ならどうなるか」を実験的に作り出します。

考え方は直感的です。売買シグナルをランダムに入れ替えて何千回も試し、その中で実際の成績より良いものが何%あったかを数える——それがp値になります。

def bootstrap_test(returns, signals, n_iter=5000, seed=42):
    """シグナルをシャッフルして、実際の成績が偶然かを検定する

    returns : 日次リターン列
    signals : ポジション(1=買い持ち, 0=ノーポジ)
    """
    rng = np.random.default_rng(seed)
    r = np.asarray(returns, dtype=float)
    s = np.asarray(signals, dtype=float)

    actual = float(np.nansum(r * s))            # 実際の累積損益
    shuffled = np.empty(n_iter)

    for i in range(n_iter):
        s_shuffled = rng.permutation(s)         # タイミングだけ壊す
        shuffled[i] = float(np.nansum(r * s_shuffled))

    p = float((shuffled >= actual).mean())

    print(f"実際の累積損益     : {actual:>10.4f}")
    print(f"ランダムの平均     : {shuffled.mean():>10.4f}")
    print(f"ランダムの95%点    : {np.percentile(shuffled, 95):>10.4f}")
    print(f"実際を上回った割合 : {p:>10.4f}  ← これがp値")
    return p, actual, shuffled

この手法の優れた点は、分布の形について何も仮定しないことです。実際のデータをそのまま使うので、裾が厚かろうが歪んでいようが問題ありません。

実データで試す

import yfinance as yf

px = yf.download("7203.T", period="5y", auto_adjust=True,
                 progress=False)["Close"].dropna()
df = pd.DataFrame({"close": px})
df["ret"] = df["close"].pct_change()

# 移動平均クロス戦略(翌日から反映)
df["ma_short"] = df["close"].rolling(25).mean()
df["ma_long"] = df["close"].rolling(75).mean()
df["signal"] = (df["ma_short"] > df["ma_long"]).astype(int).shift(1)
df = df.dropna()

p, actual, dist = bootstrap_test(df["ret"], df["signal"])
print(f"\n判定: {'偶然とは考えにくい' if p < 0.05 else '偶然の範囲内'}")

多くの単純な戦略は、ここでp値が0.2や0.3という結果になります。つまり「ランダムに売買したのと大差ない」ということです。バックテストの累積損益グラフが右肩上がりに見えても、それが偶然の範囲内であることは珍しくありません。

自己相関があるならブロック単位でシャッフルする

1点だけ技術的な注意があります。単純なシャッフルは「各日が独立している」ことを前提にしています。しかしリターンには連続性があるため、より厳密にはまとまり単位で入れ替えます。

def block_bootstrap(returns, signals, block=20, n_iter=3000, seed=42):
    """連続性を保ったままシャッフルする(ブロック・ブートストラップ)"""
    rng = np.random.default_rng(seed)
    r = np.asarray(returns, dtype=float)
    s = np.asarray(signals, dtype=float)
    n = len(s)

    actual = float(np.nansum(r * s))
    n_blocks = int(np.ceil(n / block))
    results = np.empty(n_iter)

    for i in range(n_iter):
        starts = rng.integers(0, max(1, n - block), n_blocks)
        rebuilt = np.concatenate([s[st:st + block] for st in starts])[:n]
        results[i] = float(np.nansum(r * rebuilt))

    return float((results >= actual).mean())


p_block = block_bootstrap(df["ret"], df["signal"])
print(f"通常のシャッフル  : p = {p:.4f}")
print(f"ブロック単位     : p = {p_block:.4f}")

ブロック単位のほうがp値は大きめ(=優位性を主張しにくい)になる傾向があります。こちらのほうが保守的で、実態に近い評価です。

最大の落とし穴:たくさん試すこと自体が問題

ここが本記事でいちばん伝えたい部分です。p値が0.05を下回ったからといって、安心してはいけません。なぜなら、試した回数が多ければ、偶然0.05を下回るものが必ず出てくるからです。

# まったく優位性のない戦略を100個試したら、何個が「有意」になるか
rng = np.random.default_rng(0)
N_STRATEGIES, N_TRADES = 100, 200

significant = 0
best_p = 1.0
for _ in range(N_STRATEGIES):
    fake = rng.normal(0.0, 1.0, N_TRADES)      # 期待値ゼロ=優位性なし
    _, p_two = stats.ttest_1samp(fake, 0.0)
    p_one = p_two / 2 if fake.mean() > 0 else 1.0
    if p_one < 0.05:
        significant += 1
    best_p = min(best_p, p_one)

print(f"優位性ゼロの戦略を {N_STRATEGIES} 個試した結果")
print(f"  p < 0.05 になったもの : {significant} 個")
print(f"  最良のp値             : {best_p:.4f}")
print("→ 中身が空でも、数を撃てば「有意」な戦略は必ず生まれます")

優位性がまったくない戦略を100個試すと、平均して2〜3個は「p<0.05」になります。パラメータを変えながら何十通りも検証している人は、まさにこれをやっていることになります。

補正して厳しく見る

対処法の1つが、試した回数に応じて基準を厳しくすることです。最も単純なのはボンフェローニ補正で、有意水準を試行回数で割ります

ALPHA = 0.05

print(f"{'試した数':>8s} {'必要なp値':>12s}")
for n_tests in (1, 5, 20, 50, 100, 500):
    print(f"{n_tests:>8d} {ALPHA / n_tests:>12.5f}")

print("\n例: 50通りのパラメータを試したなら、")
print(f"    p < {ALPHA / 50:.4f} でないと「有意」とは言えません")

50通り試したなら必要なp値は0.001。この基準を通る戦略はそう多くありません。厳しすぎると感じるかもしれませんが、これが「たくさん試した」ことの代償です。

より実用的な対処は、そもそも検証回数を減らすことです。思いついた条件を片っ端から試すのではなく、試す前に仮説を立てて、その1つだけを検証する。この規律があるかどうかが、統計を武器にできるかどうかの分かれ目になります。

p値だけでは足りない理由

もう1つ、統計の教科書でも強調される点があります。p値は「効果の大きさ」を何も語らないということです。

rng = np.random.default_rng(7)

# ケースA: 効果は極小だが、試行数が膨大
tiny = rng.normal(0.0002, 0.01, 100_000)
_, p_a = stats.ttest_1samp(tiny, 0)

# ケースB: 効果は大きいが、試行数が少ない
strong = rng.normal(0.015, 0.02, 40)
_, p_b = stats.ttest_1samp(strong, 0)

print(f"A: 平均{tiny.mean():.5f}  n={len(tiny):,}  p={p_a / 2:.6f}")
print(f"B: 平均{strong.mean():.5f}  n={len(strong):,}   p={p_b / 2:.6f}")
print("\nAはp値が極小だが、1トレードあたりの利益は手数料以下")
print("Bはp値が大きいが、実用的な大きさの優位性がある")

ケースAは統計的に有意ですが、1トレードあたりの期待利益が手数料を下回っていれば、実運用では赤字です。「有意である」と「儲かる」は完全に別の話なのです。

だからこそ、p値と一緒に効果量(実際にいくら稼げるか)を必ず確認してください。

  • 1トレードあたりの平均損益: 手数料とスリッページを超えているか
  • プロフィットファクター: 総利益 ÷ 総損失。1.3以上が実用ライン
  • 最大ドローダウン: 耐えられる深さか

これらの計算方法はプロフィットファクターを計算する方法最大ドローダウンを自動計算するコードにまとめています。

検証の手順としてまとめる

ここまでの内容を、実際に使う順番に並べておきます。

順番やること基準
1試す前に仮説を書き出す後付けの理由をつけない
2トレード数を確認する最低100回。少なければ結論を出さない
3ブートストラップ検定p < 0.05(試行数に応じて厳しく)
4効果量を確認するコスト差引後もプラスか
5別期間で再検証するアウトオブサンプルでも成立するか

特に5番目が実務では決定的です。統計的検定よりも、単純に「使っていない期間で試す」ほうが強力な場合が多くあります。過学習の検出という意味では、ウォークフォワード分析のほうが直感的で扱いやすいという面もあります。

手法についてはウォークフォワード最適化で過学習を防ぐ方法を参照してください。p値による検定とウォークフォワード、両方を通った戦略だけを実運用に上げる——これが安全側の運用だと考えています。

実際に運用しているチェックリストも載せておきます。バックテストが終わったら、この順に自問しています。

  • この戦略が効く理由を、成績を見る前に説明できたか? できないなら後付けの可能性が高い
  • 何通り試したか正直に数えたか? パラメータを変えた回数もすべて数に入れる
  • トレード数は100を超えているか? 超えていなければ判断を保留する
  • 手数料とスリッページを引いても残るか? 引いた後の数字だけを見る
  • 最適化に使っていない期間でも成立するか? ここが最後の関門

この5つを通る戦略は、正直なところそう多くありません。ただ、通らなかった戦略に資金を入れずに済んだという意味では、十分に元が取れていると感じています。検定の本当の価値は、良い戦略を見つけることよりダメな戦略を捨てられることにあります。

まとめ

「勝率60%だから勝てる」という思い込みを、p値で検証し直した記録でした。要点を整理します。

  • p値は「優位性がゼロでも、この成績が出る確率」。小さいほど偶然とは考えにくい
  • 勝率60%でも、40回程度の試行では偶然の範囲。100回でようやく検定に載る
  • 勝率52%を証明するには1,000回以上必要。個人の検証では届かないことが多い
  • 損益分布は正規分布ではないので、ブートストラップ検定のほうが適切
  • 優位性ゼロの戦略を100個試せば2〜3個は「有意」になる。試行回数の申告が不可欠
  • p値は効果の大きさを語らない。コスト差引後に残るかを必ず併せて見る
  • 最強の検証は「使っていない期間で試す」こと。検定はその補助

p値を学んで変わったのは、自分のバックテスト結果を疑うようになったことです。良い数字が出たとき、以前は「見つけた」と思っていましたが、いまは「何回試したっけ」と数えるようになりました。

まずは手元の検証結果でトレード数を数えてみてください。100回に届いていないなら、その勝率にはまだ何の意味もありません。そこを起点に検証設計を組み直すと、無駄な実運用を1回減らせます。

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