この記事は個人で作っている自動売買システムの運用の記録です。特定の銘柄や売買手法をすすめるものではありません。
はじめに
Raspberry Pi で日本株の自動売買ボットを動かしています。平日の朝に cron で起動して、引けのあとに止める、というだけの単純な運用です。それでも、この半年で「動かし方」まわりの事故が何度も起きました。
これまでの記事は、注文や検証の中身の話でした。
売買ボットの安全装置どうしがぶつかって、実弾の注文が届いていなかった話
板APIの異常値のせいで、ペーパートレードの好成績が「幻」だった話
今回は、その外側の話です。
この記事のまとめ
問題 起きたこと いまの形
二重起動 起動確認がプロセスを見つけられず、ボットが2つ並んで動き、取引ログに同じトレードが2行ずつ残った ボット自身が fcntl で排他ロックを取る
起動確認の誤報 同じ原因で、正常に起動したのに「起動失敗」の通知が出た 検索パターンを直した。コマンドの文字列でプロセスを探すのは脆い
番犬 監視スクリプトが、正常に待機しているボットを「止まっている」と誤判定して殺した 撤去。いまの監視は知らせるだけで、殺さない
エラー通知 同じエラーが Discord に大量に流れる 同じエラーは5分に1回まで。10回続いたら売買だけ止める
起動時の git pull コードに埋め込んだトークンが失効して、pull が失敗した 認証情報をコードから外した。pull が失敗しても前日のコードで起動する
- 二重起動:ボットが2つ動いていた
起動は cron からシェルスクリプトを呼んでいます。スクリプトは、起動前に「もう動いていないか」をpgrepで確かめていました。
PROC_BOT="python3 grip_gate.py"
if pgrep -f "$PROC_BOT" > /dev/null; then
echo "既に起動中(多重起動防止)"
else
nohup python3 grip_gate.py >> "$LOG_BOT" 2>&1 &
fi
ところが、手でフルパスを付けて起動したとき(python3 /home/ユーザー名/grip_gate.py)は、コマンドラインが python3 grip_gate.py にならないので、このパターンに引っかかりません。cron は「動いていない」と判断して、もう1つ起動します。
こうしてボットが2つ並んで動き、取引ログに同じトレードが2行ずつ記録されていました。このときは記録が二重になるだけで済みましたが、2つのボットがそろって実弾の発注条件を満たしていたら、二重に発注していたところです。
対策:ボット自身が排他ロックを取る
外から「動いているか」を探すのをやめて、ボット自身が起動時にロックを取るようにしました。
import fcntl
import os
import sys
LOCKFILE = os.path.expanduser("~/grip_gate.lock")
_lock_fp = open(LOCKFILE, "w")
try:
fcntl.flock(_lock_fp, fcntl.LOCK_EX | fcntl.LOCK_NB)
except (IOError, OSError):
print("grip_gate.py は既に起動しています(二重起動防止)")
sys.exit(0)
LOCK_EX:排他ロック。1つのプロセスしか取れない
LOCK_NB:取れなかったら待たずにすぐ失敗する
プロセスが落ちると、OS がロックを自動で外してくれる。「前回のロックファイルが残っていて起動できない」ということが起きない
どう起動されても、2つ目は必ずここで止まります。
2. 同じ落とし穴に、もう一度落ちた
1か月後の8月中旬、今度は「🚨 起動失敗」の通知が出るようになりました。ボットは正常に動いています。
原因は同じでした。起動直後の確認も pgrep -f "python3 grip_gate.py" で探していて、フルパスで起動したプロセスを見つけられなかったのです。パターンを "python3.*grip_gate.py" に直して解決しました。
2回踏んでわかったのは、プロセスをコマンドの文字列で探すのは脆いということです。起動の仕方が変わるたびに、検索パターンとずれていきます。いまも起動スクリプトは pgrep で確認していますが、二重起動を最後に止めるのはボット自身のロックです。
3. 番犬が、正常なボットを殺した
前のバージョンのボット(デイトレ用)では、「ボットが固まっていないか」を見張る番犬のスクリプトを別に動かしていました。ボットの状態を見て、止まっていると判断したら強制停止する作りです。
5月のある日、この番犬が正常に動いているボットを止めました。ボットが待機状態(エントリー条件を待っているだけの状態)にいたのを、固まっていると誤って判定したのです。デイトレのボットは、条件がそろわなければ一日中待機しているのがふつうなので、この判定の仕方そのものが合っていませんでした。
番犬は、その日のうちに撤去しました。
いまの監視:知らせるだけで、殺さない
いまは10分おきの cron で、次の5つを見ています。
ボットのプロセスが生きているか
ティックログが10分以上止まっていないか
営業日なのに、エントリー判定のログが0行になっていないか
認証トークンの取得失敗が続いていないか
板情報の取得の失敗率が高くないか
異常があれば Discord に通知するだけで、プロセスには触りません。正常なときは何も通知しません。
「固まった」と「待っている」を外から見分けるのは、思ったより難しいです。見分けを間違えたときの被害は、「殺す」なら売買の機会や建玉の管理を失うことで、「知らせる」なら通知が1本無駄になるだけです。なので、止める判断は人に残すことにしました。
4. エラー通知の間引きと、自動停止
ボットのメインループで例外が出たら Discord に知らせていたのですが、同じエラーが数秒おきに出続けると、通知が画面を埋めます。そこで、こういう扱いにしました。
ERR_NOTIFY_INTERVAL = 300 # 同じエラーの通知は5分に1回まで
ERR_AUTO_STOP_COUNT = 10 # 同じエラーが10回続いたら売買を止める
def notify_loop_error(self, e):
now = datetime.datetime.now()
sig = f"{type(e).__name__}: {e}"
if sig == self._last_err_sig:
self._consecutive_err_count += 1
else:
# 別のエラー → 新しい事象として数え直し、必ず1回は通知する
self._last_err_sig = sig
self._consecutive_err_count = 1
self._last_err_notify = None
# 同じエラーが続いたら、売買だけ止める(プロセスは生かす)
if self._consecutive_err_count >= ERR_AUTO_STOP_COUNT and not self.emergency_stopped:
self.emergency_stopped = True
self.save_state()
self.send_discord("system",
f"🚨 同一エラーが{self._consecutive_err_count}回連続 → 自動停止(売買停止)\n"
f"復旧したら resume コマンドで再開してください")
return
# 通知は5分に1回まで
if (self._last_err_notify is None
or (now - self._last_err_notify).total_seconds() >= ERR_NOTIFY_INTERVAL):
self._last_err_notify = now
self.send_discord("system", f"⚠️ メインループ例外\n```{sig[:400]}```")
ポイントは3つです。
ログには毎回書き、通知だけ間引く。 調べるための記録は減らさない
別のエラーが出たら数え直して、最初の1回は必ず通知する。 間引きのせいで、新しい種類のエラーを見落とさないようにする
自動停止しても、プロセスは殺さない。 売買のフラグだけを止めて、状態は保存したまま。原因が片づいたら、コマンド1つで再開できる
もう1つ、中継サーバー(Windows)への接続タイムアウトが累計30回になったら、そのときはプロセスごと終了させています。中継サーバーに届かない状態が続くなら、動き続けても何もできないからです。
5. 起動時の git pull が失敗した
コードの変更は、すべて GitHub 経由で Raspberry Pi に届けています(Pi 上で git pull)。前のバージョンのボットでは、起動スクリプトの中で毎朝 pull していました。
このとき、pull の URL にアクセストークンを埋め込んでいました。そのトークンが失効して、ある朝から pull が失敗するようになりました。
直したのは2点です。
トークンをスクリプトから外し、Git の認証情報の保存機能(credential store)に任せた。手で pull するときと同じ経路になる
pull が失敗しても起動は止めず、Discord に警告を出して、前日のコードのまま起動する
if ! git pull https://github.com/<ユーザー名>/<リポジトリ>.git main >> "$LOG" 2>&1; then
# pull 失敗しても起動は続行(前日分のコードで動かすほうが、止めるより安全)
discord_notify "⚠️ git pull失敗 → 前日分コードで起動続行(要確認)"
fi
「失敗したら止めるか、続けるか」は、場面ごとに決めておく必要があります。pull の失敗なら、昨日まで動いていたコードで動かすほうが安全です。一方、前の節の「同じエラーが10回」は、動かし続けるほうが危ないので止めます。
なお、トークンを直書きしていた時期があるリポジトリを公開するときは、過去のコミットの中にもトークンが残っていることに注意が必要です。
教訓
プロセスは、コマンドの文字列で探さない。 二重起動の防止は、プロセス自身が取るロックに任せる
自動で殺す仕組みは、殺す判定を間違えたときの被害を先に考える。 「固まった」と「待っている」の区別は難しい
通知は間引いても、ログは間引かない。 そして、新しい種類のエラーは必ず1回は知らせる
失敗したときに「止める」か「続ける」かを、場面ごとに決めておく
書いた人
会社員のかたわら、Raspberry Pi で日本株の自動売買システムを作って運用しています。検証や実装で失敗した話を中心に書いています。Zenn では「perkin」として、バックテストと検証の話を書いています。
ほかに、暮らしのお金を計算するサイト「いくらかかるの研究所」も個人開発しています。エアコンの年間の電気代を、機種・部屋・地域・住宅の性能から計算するシミュレーターなどを置いています(Astro + Cloudflare 製)。