「未来を1日だけカンニングするバグ」は、コードレビューでは見つからない。だから未来にスパイクを打ち込んだ

Claudeで執筆しました

バックテスト(過去の値動きで戦略の成績を試算するやつ)で一番怖いバグは、クラッシュではありません。

明日の価格をうっかり使ってしまうことです。

lookahead bias(ルックアヘッドバイアス)と呼ばれるやつで、明日の終値をちょっとでも計算に混ぜると、戦略は預言者になります。 バックテストの成績は輝き、実運用に出した瞬間に溶ける。 しかもコードは1行も例外を吐きません。むしろ数字が良くなるので、気分は最高です。 (最悪の種類のバグは、気分が良くなるバグ)

今回、ペアトレードという戦略のバックテストエンジンを書いたんですが、着手前の計画段階でこの「カンニング防止」の設計が揉めました。正確には、過去の自分が書いた計画書どうしが揉めてました。

shiftを1回入れるか、2回入れるか。ドキュメントが割れていた

カンニング防止の実装は、要は「系列を1個ずらす」(pandasでいうshift(1))です。 今日の判断は明日の取引に反映される、という現実のタイムラグをコードで再現する。

で、うちの計画書はこうなってました。

  • 全体計画(数日前の自分): 「ヘッジ比率βはshift(1)しておく。ポジションもshift(1)で翌日約定」
  • 直近の引き継ぎ指示(同じく自分): 「それ、二重遅延になってない?」

そうなんです。βで1回ずらし、ポジションでもう1回ずらすと、合計2日遅れになる。

ここが今日の本題なんですが

shiftが足りない(=カンニング)と、shiftが多すぎる(=1日遅れで売買)は、どっちも静かに数字を変えるだけで、どっちもエラーになりません。

カンニングは成績を過大に、遅延過多は成績を過小に。 出てくるのはどちらも「それっぽいシャープレシオ(成績スコア)」です。 出力を眺めて「うん、妥当そう」で通ってしまう。 皆様、shift(1)が1個多いコード、レビューで見つける自信ありますか? 自分はないです。だから目視をあきらめました。

「どこでずらすか」を都度考えるのをやめて、不変条件を1文に固定した

採った方針はこうです。

まず、守るべきルールをたった1文に固定する。

bar t(t日目)の実現損益は、t−1日の終値までの情報のみに依存する。

そして実装上のずらしは1箇所だけに置く。 シグナル計算(β・スプレッド・zスコア・売買判断)は当日の終値まで全部使ってよい。 そのかわり、損益計算のところで一発だけshift(1)「今日の判断は明日から効く」。

ずらしを1箇所に集めると、「ここにもshift要るんだっけ?」という思考が全部消えます。 二重遅延も起きようがない。ずらす場所が1つしかないので。

ただし、方針をdocstring(コード内の説明文)に書いただけでは「約束」にすぎません。 約束は破られます。3ヶ月後の自分に。

未来にスパイクを打ち込んで、過去が動かないことを証明する

そこで、この不変条件をテストで「証明」しました。やり方が気に入ってるので紹介します。

テスト1: 未来スパイク注入。 同じ価格系列を2本用意して、片方だけ未来のある日(t+k日目)に価格+500%みたいな巨大スパイクを注入します。 それぞれでβとzスコアを計算して、スパイクより前の値が全バイト一致することを検証する (近似一致ではなくnumpyのarray_equal、つまり完全一致)

未来に何が起ころうと、過去の計算結果は1ビットも動かない。 もしβの計算窓がうっかり未来を含んでいたら、スパイクが過去に「染みて」一致が崩れます。

おまけに逆側も固定しました。スパイク当日のβは変わることもassertする。 「計算窓は当日の終値まで含む」という規約自体をテストで固定しておくと、後から誰かが親切心でshiftを足したときも落ちてくれます。 (shiftの足しすぎは親切心から生まれがち)

テスト2: エントリーは判断の翌日に実現すること。 zスコアが5日目にエントリー閾値を跨ぐ人工データを作って、 損益が5日目はゼロ・6日目から発生することを厳密に検証。 「今日判断して今日の値動きで儲かる」が起きたら即失格です。

これで「カンニングしていない」と「1日余計に遅れてもいない」の両側が、回帰テストとして錠前になりました。 以後、エンジンをリファクタしてこのテストが緑なら、少なくとも時間方向のズルはないと言い切れます。

余談: 正しいエンジンがテストに落ちた話

ついでに1個白状すると、「平均回帰する系列なら利益が出るはず」という健全性テストが最初落ちました。 乱数シードの引きが悪く、合成データの取引10回ぽっちでは損失側に振れていただけ。 シードとパラメータを何通りか流して、安定して利益が出る合成データに差し替えて解決です。 エンジンのバグを疑って30分溶かしましたが、疑うべきはテストデータの乱数でした。

学び

時間方向のバグは、目視レビューではなくテストで潰す。

  • 守るルールは「bar tの損益はt−1までの情報のみ」の1文に固定して、ずらしは1箇所に集める
  • 未来にスパイクを打ち込んで、過去が全バイト一致することを証明する(足りないshiftを検出)
  • ついでに当日の値は変わることも固定する(多すぎるshiftも検出)
  • エントリーが翌日に実現することを人工データで厳密検証する

カンニングするバックテストは、あなたを褒めてきます。 褒められたときこそ、未来にスパイクを1本打ち込んでみてください。