この記事は個人で作っている自動売買システムの障害の記録です。特定の銘柄や売買手法をすすめるものではありません。
はじめに
会社員のかたわら、Raspberry Pi 5 で日本株の自動売買ボットを個人で作って運用しています。元手は約15万円の小さな口座です。2026年1月にデイトレ用のボットから始め、いまは日足のスイングトレード(数日〜数週間持つ)のボットが主力です。
構成はこうなっています。
kabuステーションAPIは Windows 上のアプリ(kabuステーション)経由でしか叩けないので、Windows 機を中継にして、判断と記録はすべて Raspberry Pi 側でやっています。
この記事の話に関係する仕組みは次の3つです。
- 銘柄ごとに「実弾」と「ペーパー」を切り替えられる。 ペーパーの銘柄は、実際には注文を出さずに、約定したことにして記録だけ残す
- 同時に持つのは1銘柄だけ。 資金が小さいので、ボット全体で建玉を1つに絞っている
- 実弾で買えなかったときは、ペーパーで記録する。 「せっかくのエントリー機会のデータは残したい」という意図のフォールバック
6月に一部の銘柄を実弾に昇格させたのですが、7月末と8月の実弾エントリーは、どちらも1株も買えていませんでした。 しかも2回目は、1回目の対策として入れた仕組みが、もともとあった別の安全装置とぶつかったのが原因でした。
この記事はその調査の記録です。銘柄は現在も運用しているため「銘柄A」と伏せ、株価はログから消しています。
この記事のまとめ
- 1回目(7/31):残高 API の数字が受渡し前の拘束を反映しておらず、発注前チェックをすり抜けて
Code=21(可能額不足)が3回。その日の発注は自動で停止した - その対策として「株数を100株ずつ減らして再発注する」処理を入れた
- 2回目(8/21):その再発注が、もとからあった「同じ向きの注文は5秒以内なら弾く」二重発注防止ロックに全部止められ、証券会社に届いていなかった
- どちらも最後は「ペーパーで記録」に切り替わり、Discord 上は普段のペーパー記録と見分けがつかなかった
- 教訓:安全装置を足したら、既存の安全装置との組み合わせを試す。フォールバックは「成功」と違う見た目で知らせる
1回目(7/31):同じ800株が3回弾かれた
ログ(system_2026-07-31.log から注文まわりの行だけを抜粋)です。
09:05:05 [ORDER] 銘柄A buy 800株 Exchange=9
09:05:06 sendorder 5xx: 500 body={"Code":21,"Message":"可能額が不足しております。ご注文内容をご確認ください"}
09:05:06 発注失敗1回目 (buy): Code=21 可能額が不足しております。…
09:05:10 二重発注防止: 銘柄A buy 800株
09:05:13 [ORDER] 銘柄A buy 800株 Exchange=9
09:05:13 sendorder 5xx: 500 body={"Code":21,…}
09:05:13 発注失敗2回目 (buy): Code=21 …
09:05:17 二重発注防止: 銘柄A buy 800株
09:05:20 [ORDER] 銘柄A buy 800株 Exchange=9
09:05:21 sendorder 5xx: 500 body={"Code":21,…}
09:05:21 発注失敗3回目 (buy): Code=21 …
ボットには「発注の失敗が3回続いたら、その日はもう発注しない」という安全装置があります。この日はそれが働いて止まりました。止まったこと自体は正しい動きです。
原因:残高 API に受渡し前の拘束が出ていなかった
現物株は約定の2営業日後に受渡しなので、直前の売買の分は受渡しまで拘束されます。ところが、発注前のチェックに使っていた残高(/wallet/cash の StockAccountWallet)には、この拘束が反映されていませんでした。
その結果、ボットは「買える」と判断して発注し、証券会社側で Code=21 になります。
対策(7/31):株数を減らして再発注する
もともと Code=21 は「リトライしても無駄なエラー」に分類していました。これを外して、失敗したら株数を減らして出し直し、それでもだめならペーパーで記録する、という処理を入れました。
# リトライしても無駄なエラーコード(21 は外した)
NON_RETRYABLE_ORDER_CODES = {"15", "16", "17", "18", "19", "100031"}
# エントリー処理(抜粋・簡略化)
ok, ap = self._place_order(sym, cp, shares, "buy")
if not ok and is_live:
for retry_shares in range(shares - 100, 0, -100): # 700, 600, ... 100
ok, ap = self._place_order(sym, cp, retry_shares, "buy")
if ok:
break
else:
# 全部だめならペーパーで記録
self.send_discord("grip", f"📝 握力 {sym}: 可能額不足 → ペーパー記録 {full_shares}株")
is_live = False
これで直ったつもりでした。
2回目(8/21):再発注が1回も送られていなかった
3週間後、同じ銘柄のエントリーでまた実弾が入りませんでした。ログはこうです。
09:19:43 [ORDER] 銘柄A buy 800株 Exchange=9
09:19:44 sendorder 5xx: 500 body={"Code":21,"Message":"可能額が不足しております。ご注文内容をご確認ください"}
09:19:47 二重発注防止: 銘柄A buy 700株
09:19:47 二重発注防止: 銘柄A buy 600株
09:19:47 二重発注防止: 銘柄A buy 500株
09:19:47 二重発注防止: 銘柄A buy 400株
09:19:47 二重発注防止: 銘柄A buy 300株
09:19:47 二重発注防止: 銘柄A buy 200株
09:19:47 二重発注防止: 銘柄A buy 100株
09:19:47 GRIP 銘柄A 全株数で失敗 → ペーパーフォールバック
[ORDER](実際に送った行)は800株の1回だけです。700株から100株までの7回は、同じ1秒の中で、証券会社に届く前にボット自身が止めていました。
原因:二重発注防止ロック
発注関数の冒頭には、QUAD-GATE という前のボットの頃から、こういうロックがありました。
# 修正前(抜粋)
def _place_order(self, symbol, price, shares, side="buy"):
now = datetime.datetime.now()
lk = f"_last_order_{side}"
lt = getattr(self, lk, None)
if lt and (now - lt).total_seconds() < 5:
self.logger.system(f"二重発注防止: {symbol} {side} {shares}株")
return False, 0
setattr(self, lk, now)
...
「同じ向き(買い/売り)の注文を5秒以内に2回出さない」という、誤発注を防ぐための安全装置です。一方、7/31 に入れた再発注のループは、失敗するとすぐに次の株数で _place_order を呼びます。「すぐに出し直す」仕組みと「すぐには出させない」仕組みが、正面からぶつかっていました。
しかもロックに止められたときの戻り値は (False, 0) で、証券会社に断られたときと同じです。そのためループは「700株もだめだった」と受け取り、そのまま最後まで回りきって、ペーパーに切り替わりました。
修正
再発注のときだけロックを迂回する引数を足しました。
# 修正後(抜粋)
def _place_order(self, symbol, price, shares, side="buy", bypass_lock=False):
...
if not bypass_lock and lt and (now - lt).total_seconds() < 5:
...
# 再発注側
ok, ap = self._place_order(sym, cp, retry_shares, "buy", bypass_lock=True)
そもそも、なぜ800株で Code=21 だったのか
8/21 の時点で口座の買付余力は約15.2万円、800株の注文額は約13万円です。表示上は足りています。
当時のボットは予算を「残高 × 90%」で計算していました。実際の上限を確かめるため、8/27 に API から手で注文を出してみた結果がこれです。
| 注文額 | 残高に対する割合 | 結果 |
|---|---|---|
| 約12.4万円 | 81.8% | 約定 |
| 約13.0万円 | 85.6% | Code=21 |
成行注文では、表示されている余力の満額までは買えないようです。境界はこの2点の間のどこかで、「成行の余力計算に1.2倍くらいの余裕がとられている(1 ÷ 1.2 ≒ 83%)」という仮説とは合います。ただ、データは2点しかありません。予算の比率は90%から80%に下げました。
遠回りした話:最初は「金額ではなく、注文パラメータがおかしいのでは」と疑い、FundType と DelivType の組み合わせを実注文で試しました。私の口座では、現物買いの FundType="AA" / DelivType=2 は通り、FundType="02" は預り区分のエラー、DelivType=0 はパラメータ変換エラー、DelivType=3 は auマネーコネクト未契約のエラーでした。ボットはもともと "AA" / 2 を使っていたので、これは原因ではありませんでした。
なぜ気づけなかったのか
フォールバックの通知が、普段の通知と同じ見た目だった
実弾が入らなかったとき、ボットは「📝 握力 銘柄A: 可能額不足 → ペーパー記録 800株」と Discord に送ります。通知は出ていたのです。
ただ、同じチャンネルには、もともとペーパー銘柄のエントリーがふだんから「📝」付きで流れていました。「失敗して代わりにペーパーにした」と「最初からペーパー」が、見た目ではほぼ同じでした。
ペーパーの建玉が、実弾の枠をふさいでいた
このボットは同時に1銘柄しか持ちません。8/21 にペーパーで記録された銘柄Aの800株は、ボットの中では「保有中」です。そのため、決済されるまで実弾銘柄を含めて、ほかの銘柄は一切エントリーできなくなっていました。 実弾が入らなかったのは、この日の1回だけでは済んでいなかったわけです。
もう一つの静かなフォールバック
今回ログを見直していて、別のフォールバックにも気づきました。6/19〜7/14 の約1か月間、次の行が493回出ています。
口座残高取得失敗 → フォールバック: ¥90,000
残高の取得に失敗すると、固定の予算 ¥90,000 で計算を続ける作りです。この行はログファイルに書かれるだけで、Discord には通知されません。そのため、この期間は口座残高とは関係のない予算で株数が決まっていました。取得に失敗した原因はわかっておらず、7/14 以降は出ていません。
直したこと
9月の頭にまとめて直しました。
-
再発注では二重発注防止ロックを迂回する(
bypass_lock=True) - 予算を残高の90%から80%に下げる(実測の境界 81.8〜85.6% より下に置く)
- ペーパー銘柄は、エントリーそのものをしない
3つめは設計の変更です。ティック単位のログは全銘柄ぶんを毎日保存していて、ペーパー銘柄の成績は、あとからバックテストで同じ売買を再現して評価できます。ボットの中でペーパーの建玉を持つ意味は、もうほとんどありませんでした。それどころか、今回のように実弾の枠をふさいでいたので、やめました。
教訓
- 安全装置を足したら、既存の安全装置と一緒に動かして試す。 「5秒ロック」も「株数を減らして再発注」も、単体では正しい仕組みでした
- 「断られた」と「自分で止めた」を同じ戻り値にしない。 区別できていれば、ループは最初のロックの時点で異常に気づけました
- フォールバックは「成功」とはっきり違う見た目で知らせる。 通知を出すだけでは足りず、普段の通知に埋もれない見た目が必要でした
- ログにしか出ないフォールバックは、ないのと同じ。 493回出ていても、誰も見ていなければ起きていないのと変わりません
- 実弾が本当に約定したかは、ボットの外(証券口座)と照合する。 ボット自身のログだけ見ていると、ボットが思い込んでいることしかわかりません
関連記事
書いた人
会社員のかたわら、Raspberry Pi で日本株の自動売買システムを作って運用しています。検証や実装で失敗した話を中心に書いています。
ほかに、暮らしのお金を計算するサイト「いくらかかるの研究所」も個人開発しています。エアコンの年間の電気代を、機種・部屋・地域・住宅の性能から計算するシミュレーターなどを置いています(Astro + Cloudflare 製)。
Zenn では「perkin」として、バックテストと検証の話を書いています。