1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Raspberry Pi と cron で動かす売買ボットを「二重に動かさない・殺しすぎない・騒ぎすぎない」ためにやったこと

1
Posted at

この記事は個人で作っている自動売買システムの運用の記録です。特定の銘柄や売買手法をすすめるものではありません。

はじめに
Raspberry Pi で日本株の自動売買ボットを動かしています。平日の朝に cron で起動して、引けのあとに止める、というだけの単純な運用です。それでも、この半年で「動かし方」まわりの事故が何度も起きました。
これまでの記事は、注文や検証の中身の話でした。
売買ボットの安全装置どうしがぶつかって、実弾の注文が届いていなかった話
板APIの異常値のせいで、ペーパートレードの好成績が「幻」だった話
今回は、その外側の話です。
この記事のまとめ
問題 起きたこと いまの形
二重起動 起動確認がプロセスを見つけられず、ボットが2つ並んで動き、取引ログに同じトレードが2行ずつ残った ボット自身が fcntl で排他ロックを取る
起動確認の誤報 同じ原因で、正常に起動したのに「起動失敗」の通知が出た 検索パターンを直した。コマンドの文字列でプロセスを探すのは脆い
番犬 監視スクリプトが、正常に待機しているボットを「止まっている」と誤判定して殺した 撤去。いまの監視は知らせるだけで、殺さない
エラー通知 同じエラーが Discord に大量に流れる 同じエラーは5分に1回まで。10回続いたら売買だけ止める
起動時の git pull コードに埋め込んだトークンが失効して、pull が失敗した 認証情報をコードから外した。pull が失敗しても前日のコードで起動する

  1. 二重起動:ボットが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 製)。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?