投資botは「作った後」が本番:沈黙して壊れる自動化を防ぐ設計


自動売買botを作ると、完成した瞬間が一番うれしい。しかし本当の勝負はそこからです。

自動化の最大の敵はバグではなく「沈黙」 ——動いていないのに、誰も気づかない状態です。私は実際に、月次リバランスが2晩連続で実行されず、しかも通知ひとつ来ないという事故を経験しました。この記事では、その原因と、沈黙を防ぐ3つの設計を共有します。

⚠️ 本記事は教育目的の情報共有です。投資は自己責任で行ってください。

目次

  1. 実際に起きたこと:通知不能の死
  2. 設計1:冪等性(何度実行しても安全に)
  3. 設計2:フェイルセーフ(迷ったら何もしない)
  4. 設計3:ウォッチドッグ(別プロセスが死を見張る)
  5. 失敗時に状態を保存しない
  6. まとめ

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) までどうぞ。

⚠️ 免責:本記事は教育目的の情報共有であり、投資助言ではありません。実際の投資判断はご自身の責任で行ってください。