取引ボットのサーキットブレーカー設計
悪いセッションの損失拡大を防ぐサーキットブレーカーを解説します。一日損失上限の発動頻度と、ボラティリティ調整が注文サイズに与える影響を確認します。
取引ボット向けのサーキットブレーカーとは、定めた上限や下限に達した時点で、自動売買戦略による注文送信を停止するルールである。戦略とブローカーの間にあるリスク管理層として機能し、戦略が同意するかどうかにかかわらず、すべての注文に適用される。何を取引するかを決めるのは戦略である。取引を実行するかどうかを決めるのはリスク管理層である。
この分離こそが設計の核心である。自らを監視する戦略には、自身の前提が崩れた瞬間に機能する独立したチェックがない。まさにその瞬間こそ、チェックが必要になる。
取引ボットのサーキットブレーカーの役割
リスク管理層は四つの要素で構成され、それぞれが特定の、ありふれた障害を防ぐために存在します。
- ポジションサイズ、銘柄ごとの想定元本エクスポージャー、発注頻度にハードキャップを設けます。これにより、本来なら際限なく拡大しかねないバグによる損失を抑えます。
- ドローダウン・サーキットブレーカーは、セッション中の口座損失が設定額に達した場合、または口座のエクイティがピークから設定額下落した場合に、新規注文を停止します。
- 注文サイズの調整では、固定の株数ではなく、直近のボラティリティやKellyベットの一部を基準に発注額を決めます。これにより、市場の値幅が変化しても、取引ごとのリスクをおおむね一定に保てます。
- 追記専用の監査ログには、リスク管理層が拒否した注文を含め、すべての判断を記録します。「戦略が誤っていた」のか「チェック自体が実行されなかった」のかを区別できる唯一の記録です。
以下では、この四つの要素を具体化します。
トレーディングボットの妥当な一日損失上限とは
一日損失上限は、セッション中の損失が基準を超えた時点で新規注文を停止する。数値の設定は好みではなく、較正の問題である。通常の市場ノイズの範囲内に設定すれば、ボットはほとんどの週で停止したままとなる。逆に、範囲を大きく外して設定すれば、発動しない。出発点は、市場自体が一定規模の下落日をどの程度の頻度で生じさせるかである。
各数値の背後にある正確なSQL
WITH daily AS (
SELECT toDate(toTimeZone(window_start, 'America/New_York')) AS d,
argMax(toFloat64(close), window_start) AS close_px
FROM global_markets.delayed_stocks_minute_aggs
WHERE ticker = 'SPY'
AND toDate(toTimeZone(window_start, 'America/New_York')) >= toDate('2017-12-01')
AND toDate(toTimeZone(window_start, 'America/New_York')) <= toDate('2026-07-31')
AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
+ toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959
GROUP BY d
),
with_prev AS (
SELECT d,
close_px,
any(close_px) OVER (ORDER BY d ASC ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING) AS prev_close
FROM daily
)
SELECT toYear(d) AS year,
countIf(close_px / prev_close - 1 <= -0.01) AS down_1pct_days,
countIf(close_px / prev_close - 1 <= -0.02) AS down_2pct_days,
countIf(close_px / prev_close - 1 <= -0.03) AS down_3pct_days
FROM with_prev
WHERE prev_close > 0
AND d >= toDate('2018-01-01')
GROUP BY year
ORDER BY year一%以上下落したセッションは、年間およそ250営業日のうち、15年に2019回、45年に2020回だった。三%以上下落したセッションの回数は性質が異なり、0年は2019回、16年は2020回だった。9の最終行は、2026年7月31日までのセッションのみを対象としている。
この推移は一定ではなく、まとまりを伴う。そこが設計上の重要点である。大きな下落日は連続して発生する。ボットがその連続初日に停止し、翌日に再開するなら、実質的には停止したことにならない。
二つの上限は異なる役割を担う。一日損失上限は通常、口座純資産の二%に設定し、そのセッションを終了させる。高値圏の純資産を基準に測るトレーリング・ドローダウン上限は、通常およそ一〇%に設定し、人による確認が行われるまで戦略を停止する。前者は日常的な制御であり、後者はまれな事象を想定したものだ。前者だけを備えたボットでは、何も発動させないまま、二%ずつ口座を削り続ける可能性がある。
ボラティリティ・スケーリングはポジションサイズをどう変えるか
ボラティリティ・ターゲティングでは、直近の実現ボラティリティに反比例するようポジションサイズを決める。日中値幅が二倍になれば、ポジションはおおむね半分になる。これにより、1取引当たりのドルベースのリスクをほぼ一定に保つ。ここでいう実現ボラティリティは、日次リターンの年率換算標準偏差を指す。実現ボラティリティは、多くの投資家が想定する以上に大きく変動する。
各数値の背後にある正確なSQL
WITH daily AS (
SELECT toDate(toTimeZone(window_start, 'America/New_York')) AS d,
argMax(toFloat64(close), window_start) AS close_px
FROM global_markets.delayed_stocks_minute_aggs
WHERE ticker = 'SPY'
AND toDate(toTimeZone(window_start, 'America/New_York')) >= toDate('2023-12-01')
AND toDate(toTimeZone(window_start, 'America/New_York')) <= toDate('2026-07-31')
AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
+ toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959
GROUP BY d
),
rets AS (
SELECT d,
close_px / any(close_px) OVER (ORDER BY d ASC ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING) - 1 AS ret
FROM daily
)
SELECT formatDateTime(toStartOfMonth(d), '%Y-%m') AS month,
round(stddevSamp(ret) * sqrt(252) * 100, 1) AS realized_vol_pct,
round(least(100.0, 1200.0 / (stddevSamp(ret) * sqrt(252) * 100)), 1) AS vol_target_size_pct
FROM rets
WHERE d >= toDate('2024-01-01')
AND isFinite(ret)
GROUP BY toStartOfMonth(d)
HAVING count() >= 15
ORDER BY toStartOfMonth(d)実現ボラティリティは、2024-01の11.1%年率換算で12%、2026-07では31カ月にわたって測定された。2列目は各数値を、ボラティリティ目標12%のポジションサイズに換算したものだ。ただし、フルラインを上限とする。2024-01に対して100%、2026-07に対して99.7%である。同じ戦略で、確信度も同じでも、保有株数は大きく異なる。
ケリー基準は、ボラティリティだけでなく、推定した優位性と分散を基にポジションサイズを決めることで、同じ問題に別の角度から取り組む。どちらの入力値も限られた標本からの推定値であるため、システマティック運用者の多くはケリー基準の一部、つまりハーフ・ケリーやクォーター・ケリーを用いる。ケリー基準によるポジションサイジングでは、その計算方法を説明する。
トレーディングボットがストップ後に再エントリーを繰り返すのはなぜか
ストップが発動する。ポジションが決済される。九十秒後、再びエントリー条件を満たし、ボットは再エントリーする。同じ損失を出して、また繰り返す。単一のコンポーネントが故障しているわけではない。ストラテジーは定義どおりに動き、ストップも定義どおりに機能している。それでも口座残高は、往復取引を一回ずつ重ねるたびに減少する。
頻度は、価格経路そのものから生じる。このパネルは、各セッションでSPYが始値を0.1%超上回る水準から、0.1%超下回る水準へ移動した回数、またはその逆方向に移動した回数を数えている。
各数値の背後にある正確なSQL
WITH mins AS (
SELECT toDate(toTimeZone(window_start, 'America/New_York')) AS d,
window_start AS ts,
toFloat64(close) AS px
FROM global_markets.delayed_stocks_minute_aggs
WHERE ticker = 'SPY'
AND toDate(toTimeZone(window_start, 'America/New_York')) >= toDate('2025-08-01')
AND toDate(toTimeZone(window_start, 'America/New_York')) <= toDate('2026-07-31')
AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
+ toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959
),
opens AS (
SELECT d, argMin(px, ts) AS open_px
FROM mins
GROUP BY d
),
zoned AS (
SELECT m.d AS d,
m.ts AS ts,
multiIf(m.px >= o.open_px * 1.001, 1,
m.px <= o.open_px * 0.999, -1,
0) AS zone
FROM mins AS m
INNER JOIN opens AS o ON m.d = o.d
),
flips AS (
SELECT d,
zone,
any(zone) OVER (PARTITION BY d ORDER BY ts ASC ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING) AS prev_zone
FROM zoned
WHERE zone != 0
),
per_day AS (
SELECT d, countIf(prev_zone != 0 AND zone != prev_zone) AS crossings
FROM flips
GROUP BY d
)
SELECT formatDateTime(toStartOfMonth(d), '%Y-%m') AS month,
round(avg(crossings), 1) AS avg_crossings_per_session,
max(crossings) AS max_crossings_in_a_session
FROM per_day
GROUP BY toStartOfMonth(d)
ORDER BY toStartOfMonth(d)SPYは2025-08に、セッション当たり平均0.7回このバンドを通過した。2026-07には1.5回だった。2026-07のあるセッションでは、通過回数が5回に達した。ある水準の一方の側でエントリーし、反対側でストップを置くルールには、1日でそれだけ発動する機会がある。
四つの仕組みで抑制できる。
- 各ストップ後に、分単位またはバー単位でクールダウンを設ける。この間、その銘柄の新規注文はリスク管理レイヤーを通過できない。
- 銘柄ごとの1日当たり取引回数の上限を設ける。これにより、無制限のループを上限付きに変えられる。
- ラッチする停止フラグを設ける。1日の損失上限に達すると、そのフラグは人が解除するまで有効なままになる。
- そのフラグをプロセスのメモリ外に永続化する。クラッシュしたボットを監視プロセスが再起動すると、初期状態から再開してしまう。まさにその状態を防ぐために、フラグが存在する。
最後の点は、他の対策をすべて正しく実装した人でも見落としやすい。グリッド取引ボットは設計上、注文を梯子状に配置する。そのため、取引回数の上限は装飾ではなく、不可欠な制御となる。
ボットが古い価格フィードで取引すると何が起きるのか
更新が止まった気配値も、数字としては表示され続けます。ボットはその値を読み取り、その価格を基に注文価格を決め、すでに動いた市場へ注文を送ります。障害は表面化しません。例外もエラーログも発生せず、約定が不自然だったと分かるのは後になってからです。
測定しやすい典型例が、オーバーナイトのギャップです。既知の価格が数時間変わらない間に、取引可能な価格が動きます。
各数値の背後にある正確なSQL
WITH daily AS (
SELECT ticker,
toDate(toTimeZone(window_start, 'America/New_York')) AS d,
argMin(toFloat64(open), window_start) AS open_px,
argMax(toFloat64(close), window_start) AS close_px
FROM global_markets.delayed_stocks_minute_aggs
WHERE ticker IN ('SPY', 'KO', 'MSFT', 'AAPL', 'NVDA', 'TSLA')
AND toDate(toTimeZone(window_start, 'America/New_York')) >= toDate('2024-01-01')
AND toDate(toTimeZone(window_start, 'America/New_York')) <= toDate('2026-07-31')
AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
+ toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959
GROUP BY ticker, d
),
gaps AS (
SELECT ticker,
d,
open_px,
any(close_px) OVER (PARTITION BY ticker ORDER BY d ASC ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING) AS prev_close
FROM daily
)
SELECT ticker,
round(quantileDeterministic(0.5)(abs(open_px / prev_close - 1) * 100, cityHash64(ticker, d)), 2) AS median_gap_pct,
round(quantileDeterministic(0.95)(abs(open_px / prev_close - 1) * 100, cityHash64(ticker, d)), 2) AS p95_gap_pct,
round(max(abs(open_px / prev_close - 1) * 100), 2) AS max_gap_pct
FROM gaps
WHERE prev_close > 0
GROUP BY ticker
ORDER BY p95_gap_pct DESC95パーセンタイルのギャップが最も大きかった銘柄はTSLAで、ギャップは4.41%でした。KOの1%を上回っています。通常の夜間ははるかに穏やかで、ギャップの中央値はそれぞれ1.01%と0.24%でした。リスク層は、こうしたテールリスクに備えるためのものです。対象期間におけるTSLAの最大単一ギャップは14.57%でした。これが、しばらく前に読み取った価格でボットが行動した場合に生じ得る価格差です。株価がオーバーナイトでギャップする理由では、その仕組みを解説しています。
対策には多額の費用はかかりません。リスク層が価格算定に使うすべての気配値に、最大経過時間を設定します。日中戦略では、一般に数秒程度です。データとは別にフィードのハートビートも取得します。これにより、無通信のソケットと市場の静けさを区別できます。データが欠落した場合は、価格を保持するのではなく取引停止として扱います。価格がなければ、ボットは手仕舞い注文の判断もできないためです。
リスク層に置くべきハードキャップとは?
- 銘柄ごとの想定元本を口座純資産に占める割合で制限する。10%を上限とすれば、一つの銘柄の不調で口座全体が左右される事態を防げる。
- すべてのオープンポジションのグロス想定元本に上限を設ける。純資産の100%に設定すればレバレッジはかからない。これはブローカーの初期設定をそのまま受け入れるのではなく、明示的に決めるべき事項である。
- 一分当たりおよび一日当たりの最大発注回数を設定する。毎分10件は大半の個人向け戦略にとって十分な水準であり、暴走したループも一分以内に抑えられる。
- 一注文のサイズを、銘柄の平均日次出来高に占める割合で制限する。1%を上限とすれば、ボットが取引しようとしている価格を自ら動かす事態を防げる。分母は平均日次出来高である。
これらはすべて戦略ではなくリスク層に置くべきであり、バックテスト、ペーパー取引、ライブ取引のいずれでも同じコードパスを通す必要がある。ライブ環境にしか存在しない制限は、誰もテストしていない制限である。
トレーディングボットの監査ログに必要なものは何か
追記専用ログは、意思決定ごとに一つのレコードを書き込み、編集や削除を行わない。各レコードには、タイムスタンプ、使用したクォートとその経過時間、実行したすべてのリミットチェックと判定結果、送信した注文、ブローカーからの応答を記録する。約定しなかった注文も、約定した注文と同じ重みで記録する。
目的は再現性の確保にある。問題のあったセッションから六週間後に問われるのは、P&Lがいくらだったかではない。どのチェックが、どの入力に対して通過したのかである。入力を記録していなければ、市場の状態からボットの状態を改めて推定することになる。これは、バックテストにおける先読みバイアスと同じ誤りである。意思決定の時点でシステムが保有していなかった情報を使うことになるからだ。
riskguardプロジェクトは、この分離を実装したオープンソースの一例である。リミットチェックを戦略内に分散させず、戦略から呼び出すコンポーネントに配置している。複数ある設計の一つにすぎず、内容を確認せずにそのまま採用すべきものではない。どの実装を使う場合でも、デフォルトブランチではなくタグ付きリリースを固定するべきだ。同じバックテストを二回実行しても、ブランチはその間に変更される可能性がある。静かに変更されたリスク層は、存在しないリスク層より悪い。
初回のデプロイをペーパーブローカーで実行する理由
ブローカーアダプターはペーパー取引をデフォルトとし、ライブ取引には明示的なフラグ設定が必要です。これは意図的な設計です。防ぐのは、ありふれた障害です。コピーした設定ファイルや、上書きされていない環境変数によって、実際の資金で注文が発注される事態です。
ペーパー取引では、もう一つ重要な成果物も得られます。ライブ価格に対して同じリスク層が判断した結果を記録したログです。どの上限が発動し、どの上限が発動しなかったかを確認できます。これはリスク層に関する証拠であり、戦略が利益を生むかどうかとは別の問題です。実資金を投入する前のペーパー取引では、ペーパー取引の記録から証明できることと、証明できないことを説明しています。また、マルチエージェントAI取引システムでは、複数のエージェントが注文を発注できる場合、取引停止の権限をすべてのエージェントの外部に置く必要がある理由を示しています。
トレーディングボットのサーキットブレーカーFAQ
トレーディングボットにおけるサーキットブレーカーとは何ですか?
リスク管理層に設けるルールです。あらかじめ定めた上限に達すると、ボットによる新規注文の送信を停止します。多くの場合、日次損失の上限や、口座の評価額が過去最高値から下落した幅を基準にします。戦略とは独立してすべての注文に適用され、解除されるまで停止状態を維持します。
市場で一日二%下落する頻度はどの程度ですか?
SPYでは、2019の二%以上下落したセッション数が5、2020のセッション数が25でした。各年の取引日はおよそ250日です。平穏な年とストレスの高い年でこれほど差があるため、損失上限は直感ではなく過去のデータを基に設定します。
停止後にボットが再エントリーしないようにするにはどうしますか?
停止するたびにクールダウン期間を設け、銘柄ごとの一日当たりの取引上限を設定します。これにより、無制限のループを制限できます。停止フラグはプロセスのメモリ外にも保持し、永続化する必要があります。そうしなければ、監視プロセスがクラッシュしたボットを再起動した際に、停止していない初期状態を渡してしまいます。
ボットは価格フィードが古くなったことをどのように検知できますか?
注文価格を算出する前に、すべての気配値の経過時間を確認します。同時に、データそのものとは別に、フィードからのハートビートも監視します。夜間のギャップは、古い価格が隠す変動の大きさを示します。2024年一月から2026年七月までの期間では、夜間変動の95パーセンタイルが4.41%に達したのはTSLAでした。
小規模なトレーディングボットにも監査ログは本当に必要ですか?
約定ログは何が起きたかを記録します。判断ログは、ボットが自らに何を許可されていると認識していたかを記録します。これは、誤った戦略と、実行されなかったリスクチェックを区別する唯一の方法です。追記専用で、拒否された注文も含める形式が、実用上の最低限です。
上記のすべての数値は、分足データに対する保存済みクエリから取得しています。各パネルから、その裏側にあるSQLを開けます。同じクエリをStrasmore terminalで独自の銘柄リストに適用してください。