Strasmore Research
Deep Dives · Matt ConnorBy Matt Connor ·

バックテストの信頼区間をブートストラップで推定

バックテストのSharpeレシオは一つの標本にすぎません。ブートストラップ法で信頼区間を作成し、ゼロを含む幅広い結果の読み方を解説します。

バックテストの信頼区間は、一つの問いに答えるものです。エクイティカーブのうち、どの程度が戦略によるもので、どの程度が対象期間の特定の相場環境によるものかを示します。バックテストをブートストラップ法で分析する場合、リターン系列を何度も再標本化し、各標本で統計量を再計算します。その結果のパーセンタイルを確認して信頼区間を作成します。日次観測値を単一年について測定したシャープレシオが1.4でも、95%信頼区間はゼロを含むほど広くなることがあります。以下のパネルでは、その理由を検証します。

一つの損益曲線は一つのサンプルにすぎない

バックテストでは、各統計量について一つの数値しか得られません。Sharpeレシオ、年率リターン、最大ドローダウン、勝率がそれぞれ一つ示されます。しかし、これらのどれも戦略の真の価値ではありません。いずれも、有限期間の取引日から算出した推定値です。同じ長さでも別の期間を選べば、異なる結果になります。

下のパネルでは、戦略を問いから完全に切り離しています。最も単純なポジションとしてSPYを保有し、終値から終値までの価格リターンを用いて、暦年ごとに測定しています。

クエリ一ポジション・一年ずつ:暦年別SPY年率換算シャープレシオ
各数値の背後にある正確なSQL
WITH daily AS
(
    SELECT
        date,
        toFloat64(close) / nullIf(lagInFrame(toFloat64(close), 1)
            OVER (ORDER BY date ROWS BETWEEN 1 PRECEDING AND CURRENT ROW), 0) - 1 AS ret
    FROM global_markets.stocks_daily_aggs
    WHERE ticker = 'SPY'
      AND date >= '2011-12-01'
      AND date <  '2026-01-01'
)
SELECT
    toString(toYear(date))                            AS year,
    count()                                           AS obs_count,
    round(avg(ret) * 252 * 100, 2)                    AS ann_return_pct,
    round(stddevSamp(ret) * sqrt(252) * 100, 2)       AS ann_vol_pct,
    round(avg(ret) / stddevSamp(ret) * sqrt(252), 2)  AS sharpe_ratio
FROM daily
WHERE date >= '2012-01-01'
  AND ret IS NOT NULL
GROUP BY year
ORDER BY year
Run this yourself

各行は約250セッションを対象とし、標準的な取引年に近い期間です。どの期間でも、ポジションの内容は変わっていません。2012の年率Sharpeレシオは1.06でした。一方、2025では0.88となり、チャートには14年分を示しています。幅広い指数を一年間保有するだけで、ヘッドラインの数値は多くの読者がノイズとして片付ける範囲を超えて変動します。同じ長さの戦略バックテストにも、少なくとも同程度の変動が生じます。列の背後にある年率換算の方法は、Sharpeレシオの解説で説明しています。期間リターンの算出方法については、月次リターンの測定方法をご覧ください。

バックテストのシャープレシオにはどれほどの幅があるか

シャープレシオの推定値の標準誤差は、観測数の平方根におおむね反比例します。この式に依存するのではなく、ばらつきを直接測定します。20年分の取引日を、一定の長さの重複しない期間に分けます。各期間内で年率換算シャープレシオを計算し、その数値がどれだけ離れるかを確認します。

クエリ重複しないSPY期間におけるシャープレシオの実測分散(2006年~2025年)
各数値の背後にある正確なSQL
WITH daily AS
(
    SELECT
        date,
        toFloat64(close) / nullIf(lagInFrame(toFloat64(close), 1)
            OVER (ORDER BY date ROWS BETWEEN 1 PRECEDING AND CURRENT ROW), 0) - 1 AS ret
    FROM global_markets.stocks_daily_aggs
    WHERE ticker = 'SPY'
      AND date >= '2005-12-01'
      AND date <  '2026-01-01'
),
indexed AS
(
    SELECT
        ret,
        row_number() OVER (ORDER BY date) AS i
    FROM daily
    WHERE date >= '2006-01-01'
      AND ret IS NOT NULL
),
blocks AS
(
    SELECT
        w,
        intDiv(i, w)                                            AS blk,
        count()                                                 AS n,
        avg(ret) / nullIf(stddevSamp(ret), 0) * sqrt(252)        AS sharpe
    FROM indexed
    CROSS JOIN (SELECT arrayJoin([21, 63, 126, 252, 504]) AS w) AS ws
    GROUP BY w, blk
    HAVING n = w
)
SELECT
    concat(toString(w), ' sessions')                          AS horizon,
    count()                                                   AS block_count,
    round(quantileDeterministic(0.05)(sharpe, blk), 2)        AS sharpe_p05,
    round(quantileDeterministic(0.50)(sharpe, blk), 2)        AS sharpe_p50,
    round(quantileDeterministic(0.95)(sharpe, blk), 2)        AS sharpe_p95,
    round(quantileDeterministic(0.95)(sharpe, blk)
        - quantileDeterministic(0.05)(sharpe, blk), 2)        AS sharpe_band_width
FROM blocks
WHERE sharpe IS NOT NULL
GROUP BY w
ORDER BY w
Run this yourself

1期間あたり21 sessionsの場合、測定したシャープレシオの5パーセンタイルから95パーセンタイルまでの範囲は-3.37から6.97で、238期間にわたる幅は10.34シャープレシオポイントです。各期間を504 sessionsまで延ばすと、幅は1.67ポイントに縮小します。ただし、対象となる期間は8だけです。ここでは二つの要素が同時に変化します。サンプル数が増えると推定値の精度は高まります。一方で、過去のデータから得られる独立サンプルの数は大きく減少します。この緊張関係が、テーマの本質です。

バックテストの信頼区間をブートストラップする方法

手順は、すべて書き出せるほど簡潔です。

  1. 戦略の実現リターン系列を用意します。各期間につき一つの数値とし、合計はN個です。
  2. そのリストからN個のリターンを、復元抽出で無作為に選びます。同じリターンが二度現れることもあれば、一度も現れないこともあります。
  3. 再抽出したデータで統計量を再計算します。
  4. 数千回繰り返し、得られたすべての値を保存します。
  5. 保存した値を並べ替え、2.5パーセンタイルと97.5パーセンタイルを読み取り、95パーセント信頼区間とします。

手順2の前に乱数生成器へシードを設定し、公表する区間の横にそのシードを記録します。ブートストラップはモンテカルロ推定です。シードを設定しない二回の実行では末尾の桁が一致しません。区間を再現できないレビュアーには、その結果を検証する方法がありません。これは、再現可能なバックテストの設定で説明したのと同じ規律です。

平均リターンとシャープレシオは、どちらもリターンのリストを集合として扱うため、手順2を問題なく通過します。一方、最大ドローダウンは異なります。ドローダウンは、時系列の順序を読み取る指標です。リターンの順序を入れ替えた再抽出では、実際の取引順序では生じなかった最大ドローダウンが得られます。それでもブートストラップを行う価値はあります。ただし、出力が何を示すものかを明記しなければなりません。これは次に発生するドローダウンの予測ではなく、順序を入れ替えた履歴全体におけるドローダウンの分布です。定義は最大ドローダウンで説明しています。

IIDブートストラップが市場リターンに適さない理由

ステップ2では、すべてのリターンが独立かつ同一分布であるというIID仮定を置いている。日次リターンはこの仮定を破り、区間の幅を変える。符号付きリターンには、一日単位の自己相関がわずかに見られる。一方、リターンの絶対値はクラスターを形成する。大きな値動きの隣には大きな値動きが続き、値動きの小さい日も連続する。

クエリ1ラグ自己相関:符号付きリターンと絶対リターン(2016年~2025年)
各数値の背後にある正確なSQL
WITH daily AS
(
    SELECT
        ticker,
        date,
        toFloat64(close) / nullIf(lagInFrame(toFloat64(close), 1)
            OVER (PARTITION BY ticker ORDER BY date ROWS BETWEEN 1 PRECEDING AND CURRENT ROW), 0) - 1 AS ret
    FROM global_markets.stocks_daily_aggs
    WHERE ticker IN ('SPY', 'MSFT', 'KO', 'XOM', 'JNJ', 'PG')
      AND date >= '2015-11-01'
      AND date <  '2026-01-01'
),
lagged AS
(
    SELECT
        ticker,
        date,
        ret,
        lagInFrame(ret, 1) OVER (PARTITION BY ticker ORDER BY date ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS ret_prev
    FROM daily
    WHERE ret IS NOT NULL
      AND abs(ret) < 0.35
)
SELECT
    ticker,
    count()                                AS obs_count,
    round(corr(ret, ret_prev), 3)          AS return_autocorr,
    round(corr(abs(ret), abs(ret_prev)), 3) AS abs_return_autocorr
FROM lagged
WHERE date >= '2016-01-01'
  AND ret_prev IS NOT NULL
GROUP BY ticker
ORDER BY abs_return_autocorr DESC
Run this yourself

SPYでは、同じ日について測定した日次リターンの絶対値の一階自己相関が、0.3662514セッションにわたる測定で、同じ日の符号付きリターンでは-0.133となっている。チャート上の任意の銘柄で、二つの棒を比較してほしい。リターン系列をシャッフルすると、このクラスター構造が失われる。そのデータにIIDブートストラップを適用すると、標本が示すより狭い区間が算出される。誤差の方向は最も望ましくない。戦略を実際より有利に見せてしまう。

移動ブロック・ブートストラップとブロック長の選択

単一のリターンではなく、連続するブロックを再標本化するのが修正策である。ブロック長をLに設定し、L個の連続するリターンから成るブロックを復元抽出する。これをつなぎ合わせ、合成系列の長さがNになるまで続ける。ブロック内の依存関係はそのまま残り、リターンは元の順序を維持する。人為的なのはブロック間の接続だけである。

ブロック長の選択では、二つの誤差のどちらを重く見るかが問題になり、両方を回避できる設定はない。短いブロックはIIDブートストラップに近くなり、区間を過小評価する。これはバイアスである。長いブロックは依存関係をより多く保持する一方、抽出できる異なるブロックの数が減る。そのため、各再標本化で同じ履歴の大きな部分が繰り返され、区間自体が不安定になる。これは分散である。公表されている経験則では、ブロック長をNの3分の1乗に比例させる。まずは出発点として扱うべきである。

より低コストの診断方法は、期間に応じて分散がどのように変化するかを確認することである。独立性が成り立つ場合、k日リターンの合計の分散は1日リターンの分散のk倍となり、両者の比率は1になる。

クエリブロック長別分散比:SPYの分散は独立抽出のようにスケールしますか?
各数値の背後にある正確なSQL
WITH daily AS
(
    SELECT
        date,
        toFloat64(close) / nullIf(lagInFrame(toFloat64(close), 1)
            OVER (ORDER BY date ROWS BETWEEN 1 PRECEDING AND CURRENT ROW), 0) - 1 AS ret
    FROM global_markets.stocks_daily_aggs
    WHERE ticker = 'SPY'
      AND date >= '2005-12-01'
      AND date <  '2026-01-01'
),
indexed AS
(
    SELECT
        ret,
        row_number() OVER (ORDER BY date) AS i
    FROM daily
    WHERE date >= '2006-01-01'
      AND ret IS NOT NULL
),
base AS
(
    SELECT varSamp(ret) AS var_1d FROM indexed
),
blocks AS
(
    SELECT
        k,
        intDiv(i, k)  AS blk,
        count()       AS n,
        sum(ret)      AS block_ret
    FROM indexed
    CROSS JOIN (SELECT arrayJoin([2, 3, 5, 10, 21, 42, 63]) AS k) AS ks
    GROUP BY k, blk
    HAVING n = k
)
SELECT
    concat(toString(k), ' sessions')                          AS block_length,
    count()                                                   AS block_count,
    round(varSamp(block_ret) / (k * any(var_1d)), 3)          AS variance_ratio
FROM blocks
CROSS JOIN base
GROUP BY k
ORDER BY k
Run this yourself

2 sessionsでは比率が0.844となり、63 sessionsでは0.584となった。78個の重複しないブロックを用いて算出した。1に近い値は、その期間において、合計値が独立抽出の場合と同じように拡大していることを示す。1から離れた値は、依存関係がなお影響している期間を示す。ブロック長はその範囲をカバーする必要がある。これを満たす最短のブロックを選び、その時点でブロック数がいくつまで減っているかを確認する。

バケット別リサンプリングと各区間に併記する件数

ボラティリティ局面や暦月ごとにバケット内でリサンプリングすると、分析のきっかけとなった条件を維持できる。「戦略が高ボラティリティ局面でシャープレシオを稼ぐ」という主張を検証するなら、高ボラティリティの日だけを抽出して作成した区間が、その主張を検証する。代償は算術的な制約である。250件の観測値を四つのバケットに分けると、各バケットはおよそ60件になる。60件の観測値でブートストラップを行うと、区間は全標本の場合の約二倍の広さになる。

したがって、毎回、すべての区間の横にバケットの件数を併記する。上のパネルにあるblock_count列は、この原則を可視化したものだ。九つのウィンドウから読み取った5パーセンタイル値と、二百のウィンドウから読み取った5パーセンタイル値は、どちらも小数点以下二桁で表示されても、異なる対象である。

ブートストラップで修正できないこと

ブートストラップが定量化するのは、手元のデータから計算した統計量に含まれる標本抽出ノイズだけです。そのデータが実際に利用可能だったかどうかは判断しません。

先読みバイアスに汚染されたバックテストは、実際には売買できなかったリターン系列を生みます。その系列をブートストラップにかけても、架空の結果の周りに狭く確信度の高い区間が返るだけです。現在の指数構成銘柄だけでユニバースを作れば、生存者バイアスが生じ、そこからのすべての再標本にも引き継がれます。三つ目の問題は選択効果です。200通りのバリエーションを試して最良のものを残すと、そのブートストラップ区間は選ばれた一つのバリエーションの標本抽出ノイズだけを示し、選択に使った残り199通りの結果を無視します。適切に算出した区間は有用です。しかし、アウトオブサンプルデータの代わりにはなりません。

データと手法に関する注記
  • リターンは日足バーの終値から終値までの価格リターンです。配当は含めていないため、すべてのリターンとシャープレシオの水準は、配当利回りの分だけおおむね低くなっています。本稿で扱う分散への影響はほぼありません。
  • ウィンドウとブロックは重複しないため、各統計量は履歴の重複しない部分から算出しています。ウィンドウを重複させると、見かけ上のサンプル数が過大になります。
  • 分位点には決定論的な推定方法を用いているため、パネルを再実行しても、新たな近似値ではなく同じパーセンタイルが返ります。
  • 自己相関パネルでは、ウィンドウ内で株式分割がなかった大型銘柄六つを使用しています。また、35%を超える値動きがあった日は除外し、価格調整によるアーティファクトが相関に入り込まないようにしています。

よくある質問

ブートストラップ信頼区間は、バックテストについて何を示しますか?

同じ期間数で同じプロセスを再びサンプリングした場合に、統計量が取り得る妥当な値の範囲を示します。95%信頼区間にゼロが含まれる場合、サンプルが短すぎて、その戦略に優位性があるのか、まったくないのかを判別できないことを意味します。

ブートストラップの再サンプリングは、何回行えば十分ですか?

95%信頼区間では、数千回の再サンプリングで通常は安定します。10,000回は一般的な初期設定で、計算コストもほとんどかかりません。裾の分位点をより正確に推定するには、さらに多くの回数が必要です。1,000回の抽出における1パーセンタイルは、約10個の値から読み取ることになるためです。

移動ブロック・ブートストラップでは、ブロック長をどのように設定しますか?

普遍的に正しい長さはありません。経験則では、サンプルサイズの3分の1乗に比例させます。実務上は、分散の拡大が線形でなくなる期間を確認する方法があります。これは上の分散比パネルで測定しています。使用したブロック長は、信頼区間とともに明記してください。

最大ドローダウンにもブートストラップを使えますか?

使えます。ただし、順序に注意が必要です。ドローダウンはリターンの並びに依存するため、シャッフルして作成した再サンプルでは、並べ替えられた履歴のドローダウンが算出されます。ブロック・ブートストラップなら短期間の連続した値を維持できるため、経路に依存する統計量にはより適しています。

ブートストラップで過剰適合を修正できますか?

できません。一つのリターン系列に含まれるサンプリング誤差を測定する手法だからです。先読みバイアスや、多数のバリエーションを検証したことによる選択効果は、ブートストラップの対象外です。


ここに掲載したすべてのパネルには、生成に使ったSQLが記載されています。ティッカーまたは期間の長さを変更し、Strasmore terminalで自分で実行してください。

#backtesting#bootstrap#statistics#confidence intervals#sharpe ratio