0
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?

ペーパーのはずの銘柄に13万円の実弾注文が出ていた ― 売買ボットの「実弾かペーパーか」の判定で1か月半に4回事故った話

0
Posted at

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

はじめに

Raspberry Pi で日本株の自動売買ボットを作って運用しています。このボットは、銘柄ごとに「実弾(本当に発注する)」と「ペーパー(発注せず、約定したことにして記録だけ残す)」を切り替えられます。

新しい銘柄はペーパーで様子を見て、良さそうなら実弾に上げる。自動売買ではよくある運用だと思います。ところが、「この銘柄は今、実弾なのかペーパーなのか」という判定で、5月から6月の1か月半のあいだに4回事故を起こしました。そのうち1回は、ペーパーのつもりの銘柄に、本当に13万円の買い注文が出ています。

前後の話はこちらです。

この記事のまとめ

日付 起きたこと 直したこと
5/11 実弾の銘柄が、発注をやり直している最中にペーパーに切り替わり、架空の約定が記録された 銘柄を選び直すきっかけを絞った
5/28 本当に建玉を持っている銘柄が、自分をペーパーだと判断した 建玉を持っていたら必ず実弾扱いにした
6/1 ペーパーの銘柄が、5/28 の修正のせいで自分を実弾だと判断した ペーパーの銘柄はエントリーそのものをしないようにした
6/17 ペーパーに設定した銘柄に、本物の買い注文が出た。通知は「ペーパー」と表示していた 判定を1つの関数に集めた

どれも「実弾かどうか」を決める材料が、何か所にも散らばっていたことが根っこにありました。

前提:当時のボットの作り

最初の3件は、前のバージョンのボット(デイトレ用)で起きました。

  • 銘柄ごとに1つずつボットのプロセスが動く
  • 朝や昼に、別のスクリプト(切り替え役)が「今日はどの銘柄を実弾にするか」を選び、結果を /tmp/active_symbol.txt.json に書く
  • 各ボットはこのファイルを読んで、自分の銘柄が選ばれていれば実弾、いなければペーパーとして動く
  • 口座は1つなので、同時に建玉を持てるのは1銘柄だけ(ロックファイルで管理)

つまり、実弾かどうかは、ほかのプロセスが書いた /tmp のファイル1つで決まっていました。

5/11:発注のやり直し中に、ペーパーに切り替わった

ある朝、ENEOS(5020)が実弾に選ばれました。流れはこうです。

  1. 09:23 と 09:28 に発注したが、2回とも Code=21(可能額不足)で失敗
  2. その間に、どれかのボットが決済を終えて待機状態に戻った。当時は「ボットが決済したら、銘柄を選び直す」作りになっていたので、切り替え役が走り、/tmp/active_symbol.txt.json を書き換えた
  3. 選び直しの結果、5020 が実弾の対象から外れた
  4. 09:33、5020 のボットが3回目の発注に進んだ。発注関数の入口で「実弾か」を確かめると、もう実弾ではない。ペーパーとして即約定した扱いになった
  5. 結果、実際には存在しない「買い 1,309円 → 損切り 1,303.5円 → -550円」が取引ログに記録された

実弾として注文を出し直している最中に、別のプロセスが前提を書き換えていました。

修正

選び直しのきっかけを、実弾の銘柄が決済したときだけに絞りました。

# 修正前
if prev_phase != "IDLE" and cur == "IDLE":
    select_best("決済後再選定")

# 修正後:ペーパー銘柄の決済で、ほかのボットの前提を変えない
if prev_phase != "IDLE" and cur == "IDLE":
    if bot.is_live:
        select_best("決済後再選定")

5/28:建玉を持っているのに、自分をペーパーだと思った

次は逆向きの事故です。切り替え役が「今日はどの銘柄も実弾にしない」と判断して、選ばれた銘柄が空(selected: [])のファイルを書きました。

ところが、そのとき実際に建玉を持っている銘柄がありました。そのボットはファイルを読んで「自分は選ばれていない=ペーパー」と判断し、本当の建玉があるのに、ペーパーとして自分の中だけで処理を完結させていました。 実弾の建玉を、ボットが管理しなくなる状態です。

修正

建玉を持っているなら、ファイルに何と書いてあっても実弾として扱うようにしました。

@property
def is_live(self):
    # 実建玉を持っているなら、切り替え役の選定に関係なく実弾扱い
    if self.shares > 0 or self.overnight_holding:
        return True
    # 切り替え役の選定ファイルに自分が含まれているか
    ...

6/1:5/28 の修正のせいで、ペーパーが実弾になった

この修正が、4日後に別の事故を呼びました。

  1. ペーパーの銘柄がエントリーの条件を満たした
  2. ペーパーなので、発注関数は「即約定」として返す。取引ログに記録され、Discord には「🔴実弾」と通知された
  3. ボットの中では shares > 0 になった
  4. 次に is_live を評価すると、5/28 に足した「建玉を持っていたら実弾」の条件に引っかかり、True になった

証券口座に建玉は立っていないので、お金の実害はありませんでした。ただ、ボットの中では実弾の建玉を持っていることになり、ログも通知も事実と違う内容になりました。shares という変数が「本当の建玉」と「ペーパーの建玉」の両方を表していたのが原因です。

修正

ペーパーの銘柄は、エントリーの判定まではするものの、発注・記録・通知には進まないようにしました。

def _skip_paper_entry(self, entry_kind):
    """ペーパー扱いの銘柄は、発注・記録・通知をしない。"""
    if self.is_live:
        return False
    # 判定したことだけをログに残して、静かに打ち切る
    self.logger.market(f"ペーパー銘柄のため{entry_kind}エントリー見送り(発注・記録なし)")
    return True

6/17:ペーパーのはずの銘柄に、13万円の実弾注文が出た

6月、今の日足スイングのボットに移り、最初の1銘柄を実弾に上げました。設定はこうです。

  • 全体の設定は mode: "live"
  • 実弾に上げた銘柄以外は、銘柄ごとの上書き設定 is_live_override: false で「ペーパーに固定」

昇格の翌営業日、ペーパーに固定していたはずの三井ハイテック(6966)が、実弾で約定しました。 1,289円 × 100株 = 12万8,900円。Discord の通知は「📝ペーパー」と表示していたのに、kabuステーションAPIには本物の注文が出ていました。気づいて手動で売り、このときはたまたま +¥4,950 で終わりました。

原因:判定の関数が3つあって、ばらばらだった

# バグ1:発注関数は、全体の mode しか見ていなかった
def _place_order(self, symbol, price, shares, side="buy"):
    if not self.live:          # self.live = (mode == "live")
        return True, price     # 銘柄ごとの上書き設定を完全に無視していた
    ...
# バグ2:銘柄ごとの判定関数は、override=True を扱っていなかった
def get_effective_live(cfg, symbol, mode_live):
    override = ...             # 銘柄ごとの is_live_override
    if override is False:
        return False
    return mode_live           # override=True でも、全体の mode を返していた

バグ3は、建玉の突き合わせなど、ほかの場所も全体の mode だけを見ていたことです。

通知を作る部分は銘柄ごとの設定を見ていたので「📝ペーパー」と正しく表示し、発注する部分は全体の mode だけを見ていたので実弾の注文を出していました。同じ「実弾か?」という問いに、場所によって別の関数が答えていたわけです。

修正

判定を get_effective_live() の1つに集め、発注も通知も突き合わせも、すべてこれを通すようにしました。

def get_effective_live(cfg, symbol, mode_live):
    override = ...
    if override is True:
        return True            # 強制実弾
    if override is False:
        return False           # 強制ペーパー
    return mode_live           # 指定がなければ全体の mode に従う

def _place_order(self, symbol, price, shares, side="buy"):
    if not get_effective_live(self.cfg, symbol, self.live):
        return True, price
    ...

あわせて、全体の設定は安全側の mode: "paper" にし、実弾にしたい銘柄だけを is_live_override: true で指定する形に変えました。「書き忘れたら実弾」ではなく「書き忘れたらペーパー」にするためです。

その後:ペーパーのエントリーそのものをやめた

この4件のあとも、今のボットではペーパーの建玉が実弾の枠をふさぐ問題(1本目の記事)が起きました。9月、今のボットでもペーパーのエントリーをやめ、ペーパーの銘柄の成績は、あとからティックログでバックテストして評価することにしました。

振り返ると、4件の事故の修正は、どれも「判定の条件を足す」か「判定の場所を集める」ものでした。最後に効いたのは、判定しなければならない場面そのものを減らすことでした。

教訓

  • 「実弾か?」を決める材料を1か所に集める。 全体の設定、銘柄ごとの設定、ほかのプロセスが書いたファイル、今の建玉。材料が散らばっていると、場所ごとに答えが変わる
  • 発注と通知で、同じ判定関数を使う。 6/17 は、通知が正しかったせいで、かえって異常に気づくのが遅れた
  • ほかのプロセスが書き換えるものを、取引の途中で読み直さない。 5/11 は、発注のやり直しの途中で前提が変わった
  • 1つの変数に、2つの意味を持たせない。 shares が本当の建玉とペーパーの建玉の両方を表していたことが、6/1 の原因だった
  • 既定値は安全側に。 設定を書き忘れたときに、実弾ではなくペーパーになるようにする
  • 実弾に上げたら、取引時間中に最小単位で確かめる。 6/17 は昇格の前に動作確認をしていたが、取引時間外だったため発注の処理を通っていなかった

書いた人

会社員のかたわら、Raspberry Pi で日本株の自動売買システムを作って運用しています。検証や実装で失敗した話を中心に書いています。Zenn では「perkin」として、バックテストと検証の話を書いています。

ほかに、暮らしのお金を計算するサイト「いくらかかるの研究所」も個人開発しています。エアコンの年間の電気代を、機種・部屋・地域・住宅の性能から計算するシミュレーターなどを置いています(Astro + Cloudflare 製)。

0
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
0
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?