投資botは「作った後」が本番:沈黙して壊れる自動化を防ぐ設計
自動売買botを作ると、完成した瞬間が一番うれしい。しかし本当の勝負はそこからです。
自動化の最大の敵はバグではなく「沈黙」 ——動いていないのに、誰も気づかない状態です。私は実際に、月次リバランスが2晩連続で実行されず、しかも通知ひとつ来ないという事故を経験しました。この記事では、その原因と、沈黙を防ぐ3つの設計を共有します。
⚠️ 本記事は教育目的の情報共有です。投資は自己責任で行ってください。
目次
- 実際に起きたこと:通知不能の死
- 設計1:冪等性(何度実行しても安全に)
- 設計2:フェイルセーフ(迷ったら何もしない)
- 設計3:ウォッチドッグ(別プロセスが死を見張る)
- 失敗時に状態を保存しない
- まとめ
1. 実際に起きたこと:通知不能の死
私の月次リバランスbotは、macOSのlaunchdで毎晩実行されるよう設定してありました。エラー時にはデスクトップ通知が飛ぶようにも作ってありました。
ところが月初のある日、2晩連続でリバランスが実行されていませんでした。通知も一切ありません。
原因は意外なところにありました。
launchdのログ出力先をクラウド同期フォルダ内に指定していたため、launchdがプロセス起動前にログファイルを開けず、
EX_CONFIGで中断していた。
つまり スクリプトが1行も実行されていなかったのです。当然、スクリプト内に書いた通知コードにも到達しません。これが「通知不能の死」です。
教訓:エラー通知は「プログラムが動いている」ことが前提です。プログラムが起動すらしない障害は、そのプログラム自身では絶対に検知できません。
すぐできる対策
- ログ出力先はローカルの確実なパスに(クラウド同期・外部ボリュームを避ける)
- スケジューラからはシェルスクリプト経由で起動し、終了コードを見て通知する
#!/bin/bash
# runner.sh — python本体に到達できない障害も拾う最後の砦
python3 rebalancer.py "$@" >> "$LOG" 2>&1
RC=$?
if [ $RC -ne 0 ]; then
osascript -e "display notification \"exit=$RC\" with title \"Bot異常終了\""
fi
exit $RC
2. 設計1:冪等性(何度実行しても安全に)
沈黙を防ぐ最良の方法は、実行機会を増やすことです。「月初の1日だけ実行」ではなく「月初10日間、毎晩試みる」にすれば、1晩PCが落ちていても翌日拾えます。
そのために必須なのが 冪等性(idempotency) ——同じ月に2回実行されても、2回売買しない仕組みです。
from pathlib import Path
from datetime import datetime
STATE_FILE = Path("state/last_run.txt")
def already_ran_this_month() -> bool:
if not STATE_FILE.exists():
return False
return STATE_FILE.read_text().strip() == datetime.now().strftime("%Y-%m")
def mark_done():
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
STATE_FILE.write_text(datetime.now().strftime("%Y-%m"))
# メイン
if already_ran_this_month():
print("今月は実行済み → 終了")
exit(0)
これだけで「毎晩起動して、必要なときだけ動く」安全なbotになります。実行機会を増やしても事故らないことが、リトライ設計の土台です。
3. 設計2:フェイルセーフ(迷ったら何もしない)
データ取得に失敗したとき、botは何をすべきでしょうか。
私の初期実装はこうでした——「株価データが取れなければ、強気相場とみなして投資を継続」。これは典型的なフェイルオープンで、非常に危険です。
なぜなら暴落時ほどデータ取得は不安定になるから。一番退避したい局面で、botは「判定できないので買い続けます」と動いてしまいます。
正しくはフェイルセーフ——判定できないなら何もしない(現状維持して翌日リトライ)。
class RebalanceAbort(Exception):
"""データ品質の問題で安全に中止するための例外"""
def check_regime():
data = fetch_market_data("SPY")
if data is None or data.empty:
raise RebalanceAbort("レジーム判定不能:データ取得失敗")
...
# 呼び出し側
try:
run_rebalance()
except RebalanceAbort as e:
alert(f"中止(安全側): {e}/翌日リトライします")
# 状態を保存しない → 明日また試みる
同じ発想で「データの網羅率チェック」も入れておくと安全です。
coverage = len(fetched) / len(universe)
if coverage < 0.90:
raise RebalanceAbort(f"データ網羅率 {coverage:.0%} < 90%")
500銘柄中200銘柄しか取れていないランキングで発注するのは、歪んだ地図で航海するのと同じです。
4. 設計3:ウォッチドッグ(別プロセスが死を見張る)
冒頭の事故が示すとおり、ジョブは自分の死を通知できません。だから別のプロセスに見張らせます。
私の場合、毎日動く「資産記録ジョブ」に、月次リバランスの実行を監視させました。
from datetime import datetime
def watchdog_check():
"""毎日動くジョブに相乗りさせる監視"""
today = datetime.now()
if today.day < 11:
return # まだ実行窓の中
last_run = STATE_FILE.read_text().strip() if STATE_FILE.exists() else ""
if last_run != today.strftime("%Y-%m"):
notify("月次リバランスが実行窓を過ぎても未実行です")
ポイントは 監視役と監視対象が別プロセスであること。同じスクリプト内に書いた監視は、そのスクリプトが起動しなければ意味がありません。
5. 失敗時に状態を保存しない
最後にもう1つ。注文が一部失敗したときに「完了」として記録してはいけません。
私の初期実装は、注文をtry/exceptで握りつぶして続行し、その後無条件で「今月実行済み」を保存していました。これだと「売却は成功、購入は全部失敗」でも完了扱いになり、全額現金のまま1ヶ月放置されます。
failures = execute_orders(orders)
if failures == 0:
mark_done() # 完全成功のときだけ記録
alert("リバランス完了", level="success")
else:
alert(f"{failures}件失敗。状態は保存せず翌日リトライ", level="warning")
冪等性(設計1)と組み合わせると、翌日の実行が現状との差分を計算して自動的に収束します。これが自己修復です。
6. まとめ
- 自動化の敵はバグより沈黙。「動いていない」が一番気づきにくい
- プログラムは自分の死を通知できない → 別プロセスのウォッチドッグが必要
- 冪等性があれば実行機会を増やせる(月初10日間毎晩試す)
- 判定不能なら何もしない(フェイルセーフ)。「不明なら強気」は暴落時に牙を剥く
- 失敗時は状態を保存しない → 翌日、差分から自己修復
- ログ出力先などの環境依存は、プログラム外の障害を生む
botを書き終えたら、次に書くべきコードは戦略の改良ではなく 「動いていないことを検知する仕組み」 です。
関連記事 → ペーパートレード(仮想売買)とは? / Alpaca APIで米国株を自動売買する方法
質問や感想は X (@QuantNobu) までどうぞ。
⚠️ 免責:本記事は教育目的の情報共有であり、投資助言ではありません。実際の投資判断はご自身の責任で行ってください。