2026年9月11日 · 再現性

同じコード、同じデータでSharpeが2通り:バックテストに必要な非決定性監査

同じコード、同じデータでSharpeが2通り:バックテストに必要な非決定性監査

同じコミット、同じparquetファイル、同じマシンで、1時間の間隔をおいて2回実行しました。Sharpeは1.34と1.19。トータルリターンは41.2%と37.8%。取引数は1,418件と1,421件。そして2本のエクイティカーブは、11,205本連続で表示上の小数桁まですべて一致した後、分岐しました。

0.15入力が同一なのに生じたSharpe差
3 / 1,418異なった取引数
11,205分岐前に一致したバー数

最初の3つの数字は厄介です。最後の数字が興味深いのは、実行全体に浮動小数点の誤差が散らばっていたわけではないと分かるからです。特定のバーで離散的な何かが起き、その後に差が積み重なりました。この記事では、そのバーの見つけ方と、0.15という差がこれまでのパラメータ探索すべてに何を意味するのかを説明します。

戦略の概要:USDⓈ-M無期限先物の出来高上位30銘柄を対象にしたクロスセクショナル・モメンタム。4時間ごとにリバランスし、12時間リターン上位5銘柄をロング、下位5銘柄をショート。過去18か月のデータを使い、手数料とファンディングを各レッグに計上します。

実行が分岐したバーを見つける

バーごとのエクイティを最高精度で記録していれば、調査は約4分で済みます。2回の実行結果を(bar_index, equity, open_positions_hash)のCSVに出力し、両方を読み込んで、初めて不一致になるインデックスを探します。別のバグのためにそのスクリプトをすでに作っていたので、午前中いっぱい調査せずに済みました。

バー11,206、2025-03-14T08:00 UTC。直前のバーでは、エクイティが有効数字13桁まで一致していました。11,206でポジションのハッシュが異なります。実行AはSOLをロング、実行BはAVAXをロング。同じ方向、同じ想定元本で、銘柄だけが違います。それ以降はすべて、この違いから派生したものです。

そこで、そのバーのランキング入力を両方の実行で出力しました。入力は同一でした。バイト単位で一致し、30銘柄もスコア30個も同じです。2つのスコアが0.0でした。まさにゼロです。両銘柄ともルックバック期間中に取引のない4時間足を記録していたため、終値同士の比率が1になり、その対数がゼロになっていました。ゼロに丸められたのではありません。ゼロでした。

2銘柄が5位で同点でした。選択対象は上位5銘柄です。どちらが選ばれるかは、ソート後にどちらが残るかで決まりましたが、ソートの挙動は私の想定と違っていました。

同点、不安定なソート、そして2年間見過ごしていたこと

ランキング処理にはグループ集計があり、その入力フレームは、非同期フェッチのたびに作り直す銘柄セットを走査する辞書内包表記から生成されていました。セットの反復順序はハッシュシードによって変わります。PythonはPYTHONHASHSEEDを固定しない限り、プロセスごとに文字列のハッシュシードをランダム化します。そのためソート前の行順が実行ごとに異なり、同点キーに対する安定ソートでないソートでは、同点の扱いも変わりました。

こうした同点は珍しい例外ではありません。構造的に発生します。特徴量が飽和したりクリップされたりする場所では、完全な同値が生まれます。出来高ゼロのバーならリターンは正確にゼロ、クリップしたzスコアは±3.0に張り付き、異なる値が少ないランク変換では多数の同点が生じ、ブール値のフィルターでは通過したものすべてが1.0になります。4時間足で18か月を対象にした今回の実行では、選択境界で同点になったバーが47本ありました。そのうち3本で選択バスケットが変わりました。残りは、すでに選択済みの2銘柄間、または選択対象外の2銘柄間の同点でした。

最初の1日ほどは、データローダーに非決定性があるのだと思い込んでいました。そちらのほうが面白い答えだからです。実際は違いました。いつだってそうです。セットと同点と、誰も記録していなかったソートの安定性に関する思い込みが原因でした。

取引3件でSharpeが0.15変わる理由

ここは反論されやすいところですが、その答えは、エクイティ比率でサイズを決めるバックテストは経路依存のシステムだということです。各レッグを現在のエクイティの8%で運用すると、バーn時点のエクイティ差が、バーn以降のすべての想定元本の差になります。

最初の分岐だけなら、影響はごく小さなものでした。実行BのAVAXレッグは9時間で2.1%下落し、実行AのSOLレッグは0.4%上昇しました。この取引後のエクイティ差は0.21%。些細な差です。しかしその時点から、2つの実行は別々の戦略になりました。ポジションサイズがわずかに違うため、ファンディングの積算額にも少し差が出ます。さらに、保有ポジションがわずかに異なったことで、後の境界上の同点2件でも再び異なる結果になりました。そのうち1件は2025-03-27に起きました。ラン全体のPnLのおよそ3分の1を生み出した6日間のトレンドの前日です。実行Aは値動きの間ずっとポジションを保有していましたが、実行Bのエントリーはリバランス1回分遅れました。

リターン差は3.4ポイントです。Sharpeの差はリターン差から想像する以上に大きくなりました。実行Bでは取引の順序が入れ替わり、よりボラティリティの高い期間に重なったため、分子が小さくなる一方で分母が大きくなったのです。小さな原因に、2つの増幅要因が重なりました。

想定元本を固定し、エントリーが現在の保有状況に左右されないなら、こうした影響はずっと抑えられます。興味深い戦略の多くは、そのどちらにも当てはまりません。

実際に影響する5つの箇所

原因症状対処法
固定していないPYTHONHASHSEEDに加え、セット/辞書の反復順序がソートに影響する実行ごとに同点の扱いが変わり、特定のバーで最初の分岐が起きるシードを固定し、銘柄を明示的な副キーにしてソートし、同点時の順序を決定的にする
同点キーに対する不安定ソート(NumPy/pandasの既定はquicksort)上記と同様。シードを固定しても残るkind="stable"を指定するか、キーを全順序にする
ブートストラップ、学習/テストのシャッフル、または合成約定のジッターで未シードのRNGを使うラン全体に差が出て、明確な分岐点がないコンポーネントごとに明示的なシードを1つ指定し、実行マニフェストに記録する
並列浮動小数点の集約(スレッド数によって加算順序が変わる)末尾数ビットに差が出る。通常は無害だが、しきい値との比較に達すると影響する調査用の実行ではスレッド数を固定する。判定境界で==を使って浮動小数点数を比較しない
固定していないライブラリのバージョン今日は再現できても、11月には再現できないデータスナップショットのハッシュと並べて、ロックファイルのハッシュをマニフェストに記録する

4行目は懸念されるほど頻繁には問題にならず、1行目は常に問題を引き起こします。

ビット単位の再現性は美徳ではなく、道具

1行変更したときに、エクイティカーブの差がその変更に起因すると判断できるよう、決定性が必要です。それがすべてです。Stratmillでは現在、各エージェントの実行でデータスナップショットのハッシュ、ロックファイルのハッシュ、すべてのシードを記したマニフェストを出力します。再実行で以前のカーブをビット単位で再現できなければ、興味深い現象として扱うのではなく、ビルド失敗と見なします。

ただし、再現できるようになったら、今度は意図的に再現性を崩してみます。異なる64個のシードで64回実行し、ばらつきを見ます。

揺らぎ幅。同じ戦略、同じデータで、同点処理と約定順序を64通りのシードで変えました。Sharpeはp5が1.12、中央値が1.27、p95が1.41。幅は0.29です。

次に、パラメータ探索の結果を見直します。最高スコアの設定は1.46でした。96個中40位の設定は1.31です。差は0.15で、揺らぎ幅の半分にすぎません。探索でこの2つの設定を順位付けできていたわけではありません。それぞれの分布から1回ずつサンプルを取り、その値を並べていただけです。

この見方に変えてから、選び方も変わりました。設定間のスコア差が、1つの設定の揺らぎを上回って初めて、探索結果を順位として扱えます。取引が1,400件ある経路依存の戦略では、揺らぎが大きく、ランキング上位3分の1が同点同然になることも珍しくありません。そうなったら、揺らぎ幅に隠されない基準で選びます。回転率の低さ、パラメータの少なさ、懐疑的な人にも説明できるコストの仮定、苦手なウォークフォワード区間での挙動の良さ。これらなら本当の同点決着になります。Sharpeの0.15差はそうではありません。

信頼する前に、もう1つやっておくとよいことがあります。今すぐバックテストを2回実行し、バーごとのエクイティを比較して、完全一致するグループか0.15差が出るグループか確認してください。15分でできる実験で、研究履歴のどれほどが戦略を測り、どれほどがハッシュシードを測っていたのかが分かります。

決定性バックテストデータエンジニアリング過剰適合python
← 記事一覧