「未来を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本打ち込んでみてください。