マルチタイムフレーム・バックテストにおける先読み排除の証明:未来を揺さぶり、過去がそれを見ていないことを証明する
「幻想なきバックテスト」シリーズの一部。
前回の記事は一つの診断法で締めくくられた。1バー・シフトテストだ。すべての約定を1バー遅らせる。もしパフォーマンスが崩壊するなら、あなたは過去でトレードしていたことになる。このテストは、先読みが執行に隠れている単一タイムフレーム戦略には十分だ — バーiで決定し、バーiで約定していた、というケースである。
マルチタイムフレーム戦略には、もう一つの静かな漏洩箇所があり、シフトテストはそこに届かない。1分足のバーでの決定が1時間足のトレンドを参照するとき、漏れはもはや約定にはなく、指標の中にある — まだ形成が完了していない上位足バーから計算された指標だ。この記事はその漏れについて、そしてあなたのエンジンにそれがないことを — コードを眺めるのではなく機械的に — 証明する方法についてのものである。
最後に立てる主張は強いものだ。私たちのマルチTFエンジンは、86,400本の実際のETHUSDT 1分足データに対して、25項目中25項目のパリティおよび漏洩チェックに合格した。これには、未来のすべてのバーを揺さぶり、過去の決定が一つも動かないことを確認する未来シフト・プローブも含まれる。そしてこのエンジンはライブボットの正典コアとビット単位で同一であるため、この証明はバックテストだけでなくライブシステムそのものの性質なのである。
マルチタイムフレームの地雷原

マルチタイムフレーム(マルチTF)戦略は、遅い時計を参照しながら速い時計で決定を下す。私たちのエンジンは具体的な実例だ:上位タイムフレーム(HTF)トレンドと中位タイムフレーム(MTF)トレンドがエントリーをゲートし、下位タイムフレーム(LTF)のクロスがそれをトリガーするトリプルTFモメンタムルールである。1分足のベースクロック上で、1分足のHMA/HMA3クロスが1時間足トレンドと15分足トレンドの方向と一致したときにのみエントリーが発火する。上位タイムフレームのトレンドがポジションに逆行して反転した場合、またはLTFが逆クロスした場合にエグジットが発火する。
危険は構造的なものだ。これらの上位タイムフレームの値はそれぞれ、すべての1分足バーで — 1時間あたり60回 — 答えを出さなければならず、その答えはライブボットがその瞬間に知り得たものだけを使わなければならない。バーごとの約180回の読み取り(3つのタイムフレーム、エントリーとエグジットのトレンド、セパレーション・ゲート)のうち一つでも、まだ印字されていないバーに触れた瞬間、先読みが発生する。off-by-one(1つのずれ)が起きうる範囲は膨大であり、執行時の漏洩とは異なり、約定がシフトすることで自己申告してくれるわけでもない。
形成中のバーにはまだ最終終値がない
これが正確な罠だ。今が10:37で、たった今閉じた1分足バーについて決定しているとしよう。あなたのルールは1時間足トレンドを必要とする。10:00〜11:00をカバーする1時間足のローソク足はまだ形成中であり — その最終終値は10:59:59まで判明しない。10:37の時点で実際に何が分かっているのか?分かっているのは、そのローソク足のそれまでの進行中の終値、つまり10:37バーの終値だけだ。そのローソク足の最終終値は23分先の未来にある。
素朴なマルチTFバックテストは、完全に無害に見えることをやってしまう:1分足の系列全体を最初に一度だけ1時間足にリサンプルし、その後、各1分足バーについて「1時間足の終値」を読み取る。しかし10:00〜11:00の間のすべての1分足バーについて読み取られる値は、最終の10:00〜11:00の終値だ — リアルタイムでは、その時間が終わるまで存在しない数値である。その1時間の中のすべての決定が、静かに最大59分先の未来へと引き渡されてしまう。そしてこれは小さな漏れではない — 上位タイムフレームの終値は、これからトレードしようとしている近未来の1分足リターンに対する、ほぼ最強の予測因子であるため、それを漏らすことは答案の解答を読むことにほぼ等しい。これはタクソノミーにおける指標漏洩チャネルの増幅版だ:覗き見は1バー分ではなく、HTF期間まるまるに達しうる。
前作の1バー・シフトテストはこれを捉えない。約定を1バー後にシフトさせても、リサンプルされた1時間足系列は依然として汚染されたままだ — 漏れは指標がどう構築されたかの中に生きており、いつ取引したかの中にはないからである。
ライブボットのルールを正確に再現する:確定足(closed-bar)セマンティクス

正しいルールとは、ライブボットが既に実行しているものだ。私たちのコードベースでは、それは小さなクラスRunningCandleBufferであり、ライブのティック・シミュレーターからそのまま切り出されたものである。それはベースバーを固定期間のローソク足へとストリーミングし、すべてのベースバーごとに、非常に特定の配列からHTF指標を計算する:
all_closes = np.array(self.closes + [self.current_close], dtype=np.float64)
これを文字通りに読んでほしい。self.closesは、既に閉じたローソク足の最終終値である — ローソク足は新しい期間境界を跨いだときにのみ追加され、その保存される値はその期間内の最後のベースバーの終値である(candle_buffer.py:39–44)。形成中のローソク足が寄与するのはちょうど1つの数値、self.current_closeだけであり、これは進行中の終値 — 直近のベースバーの終値、base_close[i]である。それはバーiにおいて定義上既知の量だ。形成中のローソク足の最終終値は決して使われない。まだ存在しないからだ。
つまり10:37時点でのHTF指標は[..., close(9:00 candle), close(10:37 so far)]を見ている。10:38が印字されると、最後のスロットはclose(10:38)に更新される。11:00を跨ぐと、close(10:59)が新たに閉じたローソク足の最終値となり、新しい形成中スロットが開く。その1時間の中のどの時点でも、決定が最終的な10:00〜11:00の終値に触れることはない。これが確定足セマンティクスだ:閉じたローソク足は完了した終値を寄与し、形成中のローソク足は進行中の終値のみを寄与する。
私たちの高速エンジン(engine_multitf.py)は、これをベクトル化しnumbaでコンパイルした再実装である。増大するリストを持つPythonループの代わりに、すべてのベースバーiについて、何本のローソク足が完全に閉じたか(n_closed[i])を事前計算し、HMA/HMA3のウィンドウを[closed candle closes…, base_close[i]]として並べる — 進行中の終値を最終スロットに固定する形だ(engine_multitf.py:168–169)。これは同じ数式を、方向性セパレーション・ゲート付きで3つのタイムフレームにわたって速度のために展開したものである。契約は明示的だ:バーiにおける値はbase_close[0..i]のみに依存する。
これが主張だ。この記事の残りは、それを私たちがどう証明したかである。docstringの中の主張には何の価値もないからだ。
パリティは必要だが十分ではない
ストリーミングクラスを明示的なループに展開するベクトル化numbaエンジンは、まさにoff-by-oneが繁殖する場所である。そこで最初のゲートは、実データのスライス — ETHUSDT 1分足、2024年1月〜2月、86,400本のバー — に対する、正典リファレンスとのビット単位のパリティテストである。
私たちは2つの独立したリファレンスに対して、2つの独立した事柄をチェックする:
- 指標とクロス vs バーごとに実行した
RunningCandleBuffer。各タイムフレームについて、私たちはライブクラスを全86,400バーにわたって再生し、クロスイベント — バー、方向、セパレーション — が厳密に一致することに加え、HMA/HMA3の値が浮動小数点の許容誤差内であることを比較する(リファレンスはnp.dotを使い、エンジンは明示的なループを使うため、加算の順序が~1e-15程度異なる)。クロスは厳密に一致する:HTF(1時間足、HMA長21)で408回のクロス、MTF(15分足/14)で2,792回、LTF(1分足/50)で3,691回。バーや方向が異なるクロスイベントは1つもない。 - トレード vs リファレンス自身のクロスに駆動された、トレーディングルールの独立した純Python実装によるシミュレーション。これはライブのバックテストのループを再現する — エントリー・トレンドがエントリーをゲートし、エグジット・トレンドの反転または逆方向のLTFクロスがクローズし、約定は
open[i+1]、往復手数料0.09%、最終バーでの強制クローズ — エンジンのnumba機構は一切使わない。その上で、トレードごとに比較する:エントリー/エグジットのバー、サイド、エントリー/エグジット価格、PnL、エグジット理由、そしてポジション保有時間の合計。
このテストにおけるセパレーション閾値は、無害なデフォルト値ではない。それらは意図的に扱いにくいコーナーケースを突くように選ばれている — マッチするエントリー閾値よりも高く設定されたMTFのエグジット閾値 — これはリファレンスが反転として扱う「ポジションが開いている間にエグジット・トレンドが初めて定義される」ブランチを強制する。パリティは簡単な経路だけでなく、コーナーケースでも成立しなければならない。
フィールドごとに、トレードは同一である:デュアル構成で466件のトレード、トリプル構成で211件のトレード、合計PnLは1e-12まで一致し、すべてのトレードのフィールドが許容誤差内で一致する。コードを一切共有しない2つの実装 — コンパイルされたベクトル化エンジンと、第三の実装のクロスに対する素朴なPythonループ — が、小数点以下最後の桁まで同じトレードを生成する。
これは強力な結果であり、しかし先読みがないことの証明ではない。パリティは、高速エンジンがリファレンスを忠実に再現していることを示すだけだ。もしリファレンス自体が漏洩していたら — もしRunningCandleBufferが覗き見していたら — パリティはその漏れを忠実に再現し、合格してしまう。実装間の一致は、それらが同一であることを教えてくれるだけで、それらが因果的であることは教えてくれない。因果性のためには、エンジンに対して過去が未来を見ることができるかどうかを直接問う、異なる種類のテストが必要である。
未来シフト・プローブ:実際の証明

先読みの定義は操作的なものなので、操作的にテストしよう。先読みとは過去の決定が未来のデータに依存することを意味する。その対偶はテスト可能な形になる:もし未来を変えて、過去の決定が一つでも動いたなら、過去は未来を読んでいたということだ。そこで未来を変えてみよう — 容赦なく — そして過去を観察する。
系列の60%の位置にカット点jを選ぶ(86,400本中の51,840本目のバー)。j以降のすべてのバーを揺さぶる:未来のすべての終値と始値を1.05倍する。摂動を加えたデータで、3つのタイムフレームすべてについてシグナルスタック全体を再計算する。そしてjより厳密に前のすべてが、摂動なしの実行とビット単位で同一であることを検証する:
j = int(n * 0.6) # bar 51,840
cl2 = cl.copy(); cl2[j:] *= 1.05 # shove the future up 5%
op2 = op.copy(); op2[j:] *= 1.05
base = [precompute_tf_signals(cl, ts, p, L) for (p, L) in tf_params]
pert = [precompute_tf_signals(cl2, ts, p, L) for (p, L) in tf_params]
for s0, s1 in zip(base, pert):
assert eq_nan(s0.hma[:j], s1.hma[:j]) # HMA identical, NaNs included
assert eq_nan(s0.hma3[:j], s1.hma3[:j])
assert np.array_equal(s0.cross[:j], s1.cross[:j]) # every cross event
assert np.array_equal(s0.sep[:j], s1.sep[:j]) # every separation
「近い」ではない。「許容誤差内」でもない。np.array_equalであり、NaNはNaNと一致することが求められる — 51,840本の過去のバーにおける、すべてのHMA値、すべてのHMA3値、すべてのクロスフラグ、すべてのセパレーションが、同じ浮動小数点数でなければならない。そして同じ検証をトレードにも適用する:エグジットがjより前にあるすべてのトレードは、フィールドごとに変わっていてはならない。もし未来への5%の押し込みが、過去のHMAをたった一つでも12桁目で動かしたなら、過去の決定が未来を参照していたことになり、プローブは失敗する。
私たちのエンジンはこれに合格する — 3つのタイムフレームすべて、そしてデュアルとトリプル両方のトレードシミュレーションで。34,560本の未来のバーを揺さぶっても、51,840本の過去のバーと、その中でクローズしたすべてのトレードは、まったく元のままである。これは実装間の一致ではなく、時間における情報境界が保たれているという直接的な実証である。
失敗しえないテストは何も証明しない
上記のプローブに合格する方法の中には、何も証明しない方法が一つある:もし摂動が何もしない(no-op)ものであれば — もし未来を1.05倍してもどこも何も変わらなかったとしたら — 「過去は変わっていない」というのは自明に真であり、まったく無意味だ。失敗しえないテストにおけるグリーンチェックは、テストがないより悪い。それが偽の確信を生み出すからだ。そこでこのプローブには、実効性を与えるための2つの追加検証が付いている。
未来は実際に変化しなければならない。 私たちは、摂動が[j, n)のどこかでクロスを実際に変化させたことを検証する:
assert not np.array_equal(s0.cross[j:], s1.cross[j:]) # probe has teeth
これで結果は意味を持つ:未来を書き換えたのと同じ5%の押し込みが、過去をビット単位で完全に同一のまま残したのである。摂動は本物であり、前方に伝播し、そしてカット点でぴたりと止まる。片側だけの漏れ — 過去が未来を読む — であれば、jを越えて後方に染み出していたはずだ。しかしそうはならない。
境界は現在のバーちょうどにある — 1つ早くも、1つ遅くもない。 より微妙な失敗としては、因果的ではあるが古いエンジンというものがありうる:現在のバーの進行中の終値を無視して1バー遅れて反応する(漏れはないが、ライブトレーディングには存在しないラグがある)、あるいは1バー早く反応する(1バー分の漏れ)。そこで私たちは単一のバーjを(1.02倍で)摂動させ、同時に2つのことを検証する:過去[0, j)は無傷であること、かつhma[j]が即座に反応すること。
cl3 = cl.copy(); cl3[j] *= 1.02 # nudge exactly one bar
s3 = precompute_tf_signals(cl3, ts, p_ltf, L_ltf)
assert eq_nan(s0.hma[:j], s3.hma[:j]) # nothing before j moves
assert s0.hma[j] != s3.hma[j] # bar j reacts on the same bar
これによって境界が正確に固定される。形成中のローソク足の進行中の終値は、バーjにおいて遅延ゼロかつ予知ゼロで指標に入る:バーjはそれ自身の終値を即座に見るが、それより前のどのバーもそれをまったく見ることがない。それがまさに、確定足セマンティクスが乗るべきナイフエッジであり、このテストはエンジンがそこに乗っていることを確認する。
これがゲート全体である — このエンジンと捏造されたバックテストとの間に立つ、全25項目のチェックだ:
| グループ | 各チェックが検証する内容 | 件数 |
|---|---|---|
| 指標とクロス(3タイムフレーム×) | RunningCandleBufferに対するクロスイベントの厳密一致;クロス時のセパレーション;HMA/HMA3値(rtol 1e-9) |
9 |
| トレード(デュアル+トリプル) | トレード件数;フィールドごとの完全一致;合計PnLの1e-12までの一致;ポジション保有時間 | 8 |
| 未来シフト・プローブ(デュアル+トリプル) | 過去のシグナルがビット単位で不変;プローブの実効性(未来が実際に変化したこと);jより前のトレードが不変;単一バー摂動が局所化されていること |
8 |
| 合計 | 25 |
最初の2グループは、高速エンジンが正典リファレンスであることを立証する。3つ目のグループは、そのリファレンスが因果的であることを立証する。3つすべてが必要だ:漏洩する高速エンジン、因果的だが漏洩するエンジン、因果的だがラグのあるエンジんは、それぞれ異なる3つの失敗モードであり、このゲートはそれぞれを排除する。
なぜこのプローブはタイムフレームに依存しないのか
未来シフト・プローブの優雅さは、漏れがどこに存在するかを知る必要も気にする必要もない点にある。タイムフレーム、リサンプリング、ローソク足の境界について一切言及しない。ただ一つだけ問う:未来を摂動させることで過去が動くか?これが、シフト・ザ・フィル・テストが取りこぼすマルチTFの漏れに対して、まさに正しいツールとなる所以である。
素朴な「系列全体を先にリサンプルする」バグを直接考えてみよう。もし1時間足のストリームが系列全体を事前にリサンプルすることで構築されていたら、バーj-1000(jの後に閉じる時間の中に十分収まっている)で読み取られる「1時間足の終値」は、j以降のバーに依存する最終終値を持つローソク足の最終終値になってしまう。未来を1.05倍すると、その最終終値は変化する — つまりj-1000でのHTF指標が変化し、j-1000でのゲートが変化し、過去の決定が動いてしまう。プローブは即座に、カット点よりも1000ステップ前のバーで、HTFストリーム上で点灯するだろう。
私たちのエンジンのHTFストリームは動かない。なぜならj-1000において、形成中のローソク足が寄与するのはbase_close[j-1000] — 過去の終値 — だけであり、そのローソク足の最終終値は境界を通過するまで決して参照されないからだ。プローブはメカニズムに対して盲目でありながら、それでもバグを捕まえる。これはまさに証明に求められることだ:振る舞い(過去の決定が未来のデータに依存しない)を制約するのであって、実装(リサンプルのインデックスを正しく取ったか?)を監査するのではない。振る舞いこそが取引されるものであり、実装はそれに一致していることを願うものにすぎない。
バックテストとライブは一つの真実を共有する

これが典型的なバックテスト監査以上に重要である理由がもう一つある。エンジンが照合対象として証明されているリファレンス — RunningCandleBuffer — は、バックテストを良く見せるために書かれたテストフィクスチャではない。それはライブボット自身のローソク足ロジックであり、本番で稼働しているティック・シミュレーターからそのまま持ち上げられたものだ。プローブが検証する確定足ルールは、ライブボットがバーごとに実行しているルールそのものである。
つまりパリティ・ゲートは二重の役割を果たしている。高速エンジンがリファレンスに一致することを証明し、そしてそのリファレンスがライブコアそのものであるがゆえに、高速エンジンがライブに一致することも証明する。前作は、漏れがバックテストとライブのパリティ・ギャップに対する最もクリーンな説明であると警告した — ライブボットは機械的に覗き見ができない唯一の場所であり、覗き見をするバックテストは実運用に移った瞬間に乖離する。ここでは、そのギャップが構造上閉じられている。バックテストとボットは一つのローソク足バッファ、一つのクロスルール、「バーiで既知」の一つの定義を共有しているのだ。探索が最適化する数値は、ボットが実際にトレードできる数値そのものである。
これが、先読みがないことを仮定するのではなく証明することの意味のすべてだ。マルチTFの探索は数千の設定を実行する。もしエンジンが漏洩していたら、探索はその漏れを最も積極的に悪用する設定を見つけ出し、捏造された勝者をあなたに手渡すことになる — これはタクソノミーが純粋なノイズからシャープレシオ15を計測した、あの失敗モードだ。このプローブこそが、資金を投入する前に、その勝者が本物であると信頼できるようにしてくれるものである。
このプローブが証明することと証明しないこと
テストに対する厳密さは両刃の剣であるため、その範囲について正確でなければならない。未来シフト・プローブが証明するのは、単一の、特定の性質である:このデータにおいて、バーj以前のいかなるシグナルもトレードの決定も、jより後のいかなるバーにも依存していない — つまり指標、クロス、トレードの経路を通じて、時間における情報境界が保たれているということだ。これはまさに私たちが排除しようとしていたマルチTFの漏れであり、コードレビューでは確立できない性質である。
このプローブは戦略にエッジがあることを証明しない。完全に因果的なエンジンであっても、正直に損をすることはありうるし、プローブはそれについて何も語らない — それでいいのだ。漏洩がないことを証明することと、本物のエッジを見つけることは別の仕事であり、それらを混同することが、漏れたバックテストがデプロイされる原因になる。このプローブは時間に関係しないバイアスをカバーしない:銘柄における生存バイアス、エンジンが良く見えた後にのみプローブを走らせることによる選択バイアス、あるいは甘すぎる手数料モデルなどだ。そしてこのプローブ単体では、ライブの約定がバックテストの約定と一致することも証明しない — スリッページとレイテンシは、プローブが見ることのできない本物のギャップである。なぜならこのプローブは決定経路上で動作するのであって、執行の場では動作しないからだ。そのギャップを閉じるのは、エンジンのローソク足コアがライブボットのそれとビット単位で同一であるという、別の事実である。
このプローブ自体の設計について、一つ正直な留保点がある:これは単一のj(系列の60%)で切断している。検証している性質はjに関して一様である — バー51,840に特別な点は何もない — ので、一つのカットは構造的性質に対する公正なテストではあるが、より慎重なバージョンならjを系列全体にわたって掃引するだろう。私たちは、一つの適切に選ばれたカットと単一バーの局所化チェックで十分だと考えている。なぜなら、34,560本の未来の摂動からは隠れながら、別のカットでは現れるような漏れは、非常に奇妙なバグでなければならないからだ。重要なのは、証拠の限界を知ることであり、単一のテストを普遍量化子であるかのように装うことではない。
まとめ
- マルチタイムフレーム戦略は約定ではなく形成中のバーを通じて漏れる。 未完了のHTF期間内の決定は、そのローソク足の進行中の終値(最新のベース終値)だけを使わなければならず、決してその最終終値を使ってはならない。系列全体を先にリサンプルするバックテストは、期間内のすべての決定を、HTF期間まるまる分の未来へと引き渡してしまう。
- 1バー・シフトテストはこの漏れに届かない。 それは執行の先読みを捉えるものであり、マルチTFの漏れは指標がどう構築されたかの中に生きている。異なるプローブが必要だ。
- ライブのルールを正確に再現し、それから証明せよ。 私たちはボットの
RunningCandleBufferの確定足セマンティクスをベクトル化エンジンとして再構築し、25項目のチェックの背後にゲートを設けた:リファレンスに対するクロスの厳密一致(408 / 2,792 / 3,691)、フィールドごとに同一のトレード(デュアル466件、トリプル211件)、1e-12までのPnL。 - パリティは必要だが十分ではない。 リファレンスに一致することは、あなたが同一であることを証明するのであって、あなたが因果的であることを証明するのではない。忠実に再現された漏洩リファレンスは、依然として漏洩している。
- 未来シフト・プローブこそが実際の証明である。
j以降のすべてのバーを摂動させ、jより前のすべてのシグナルとトレードがビット単位で不変であることを検証せよ。未来が過去を動かせるなら、先読みが存在する。 - プローブに実効性を持たせよ。 未来が実際に変化したこと(摂動がno-opでないこと)、そして単一バーの微調整が同じバーで反応すること(漏れもラグもないこと)を検証せよ。失敗しえないテストは何も証明しない。
- バックテストとライブが一つのコアを共有するとき、証明は転写される。 エンジンがライブボットのローソク足ロジックとビット単位で同一であるため、先読みがないことはライブシステムの性質でもある — そしてバックテストとライブのパリティ・ギャップは構造上閉じられる。
前作は、たった1行の漏れがどうやってシャープレシオ15を捏造するかを示した。今回は逆の鍛錬を示した:「漏れがどうあなたを騙すか」ではなく、特定のエンジンが漏れていないことを、機械的に、実データ上で証明する方法だ。未来を揺さぶれ。過去がひるまなければ、あなたは現在でトレードしているのである。
Authors
Trading-systems engineer
Trading-systems engineer building bots since 2017: cross-exchange arbitrage (connected up to 30 venues), cointegration-based pairs arbitrage across spot and futures, scalping, news and sentiment-driven strategies, trend algorithms, and portfolio management and balancing algorithms. Also builds sub-millisecond order execution, big-data warehouses, backtesting engines, AI agents, and trading interfaces (incl. open-source profitmaker.cc). Stack: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, architecture.