AI自動売買を実運用して見つかった「静かに失敗する」バグ5選【エラーは出ないのに結果が間違う】
- この記事で分かること
- 実データで運用して初めて見つかった5つのバグ(90日前のデータを今日の値動きと誤認・SELLが0株に丸められて消える・価格取得失敗で評価額0円・レート制限でTOPIXが欠落・N+1の問い合わせでレート制限)の原因と修正
- 実際に使った環境
- Python / Groq API / kabuステーションAPI / J-Quants API(無料プラン)。2026年9月11日から実際に発注している自動売買システムで起きた不具合です
- 筆者の結論
- 5つのうち4つは「失敗をNoneや0で黙って吸収する」書き方が根っこにあった。止まらないことを優先した設計が、間違った値を実績として公開する事故につながっていた
AIに30万円を渡して日本株を自動売買させる実験は、2026年9月11日から実際の証券口座で発注しています。運用を始めてから約2週間で、いくつものバグが見つかりました。
振り返ると、見つかったバグの多くには共通点がありました。エラーで止まらないことです。プログラムは正常に動き続け、記事も運用状況ページも毎日きちんと更新される。ただ、その中身の数字やAIの判断が、静かに間違っていました。この記事では、その中から5つを取り上げて、原因と直し方を記録します。
🛠 実装してみて分かったこと
自動売買のシステムを作るとき、「1銘柄のデータが取れないくらいで全体を止めたくない」と考えて、失敗したらNoneを返す、0で埋める、という書き方をあちこちでしていました。止まらないこと自体は狙いどおりでしたが、その代わりに「間違った値のまま最後まで走り切る」ようになっていました。止まるバグは気づけますが、止まらないバグは、誰かが数字の違和感に気づくまで残り続けます。
1. AIが90日前の値動きを「今日の急騰」だと思い込んでいた
症状
ウォッチリストに新しく加えた銘柄の多くが、AIの判断で「SKIP(見送り)」になっていました。判断理由を読むと、「現在値が本日の始値・高値・安値から大きく外れており、説明のつかない急騰(急落)が起きている」と書かれています。
原因
実際の数字を突き合わせると、AIに「本日の始値・高値・安値」として渡していた値が、実はJ-Quants無料プランの約90日前のデータでした。無料プランのデータは約90日遅延しており、その時点で最新の行は、ちょうど90日前の日付のものでした。
しかも、AIに渡すデータには日付のラベルが付いていませんでした。AIからは、それが今日の値なのか90日前の値なのか区別する手段がありません。例えばソニーフィナンシャルグループ(8729)では、「本日の値幅」が139.9〜142.0円なのに、現在値は161.9円。90日間の普通の値動きを、AIは「今日の異常な急騰」と読み取って、防御的に見送っていたわけです。
この問題は運用開始当初からウォッチリスト全体で起きていたとみられます。もともと入れていた大型株では値動きの差が小さく目立たなかっただけで、新しく入れた中小型株で表面化しました。
修正
kabuステーションAPIの板情報(こちらはリアルタイムの当日データ)から、当日の始値・高値・安値・前日終値を取得するようにしました。AIへの入力では、J-Quants由来の古い値を取り除き、日本語のラベルを付けた当日の実データに差し替えます。
if live_intraday is not None:
for stale_key in ("open", "high", "low", "adj_close"):
indicators.pop(stale_key, None)
indicators.update(live_intraday)def _live_intraday_dict(snapshot: BoardSnapshot) -> dict:
"""`BoardSnapshot`を、AIへの入力用の日本語ラベル付き辞書に変換する。
値が取れなかったフィールド(None)は含めない。"""
fields = {
"本日始値": snapshot.opening_price,
"本日高値": snapshot.high_price,
"本日安値": snapshot.low_price,
"前日終値": snapshot.previous_close,
}
return {label: value for label, value in fields.items() if value is not None}修正後、ソニーフィナンシャルグループの「本日の値幅」は161.2〜164.0円になり、現在値と矛盾しない範囲に収まりました。なお、過去のデータで判断を再現するバックテストでは、その日の値をそのまま使うのが正しいため、この差し替えは行っていません。
教訓: 遅延のあるデータをAIに渡すときは、値だけでなく「いつの値か」も渡す。人間なら違和感に気づける数字も、AIは渡された情報を前提に筋の通った理由を組み立ててしまいます。
2. AIの「売り」判断が、0株に丸められて消えていた
症状
「なかなか売る判断にならない」と感じていました。AIの判断記録を見ると、SELLの判断自体は何度も出ています。それなのに、対応する注文が1件も作られていませんでした。
原因
AIは「保有100株のうち50株を売却するのが妥当」のように、単元株(100株)に満たない数量を提案していました。日本株は100株単位でしか売買できないため、発注前の処理で数量を100株単位に切り捨てています。50株は0株に切り捨てられ、0株の注文は作られません。
問題は、この切り捨てが何の記録も残さずに行われていたことです。リスクチェックの記録すら残らないため、判断の記録を見る限り「SELLと判断した」のに、実際には何も起きていない状態になっていました。
さらに根本の原因はプロンプトにありました。BUYについては「購入可能な株数の目安」を具体的に伝えていたのに、SELLについては「単元株数の倍数で」という抽象的な指示しかしていませんでした。保有がちょうど100株の銘柄が多く、その場合に選べる有効な数量は「100株(全部売る)」か「売らない」の二択しかないのに、それをAIに伝えていませんでした。
修正
保有株数に応じて、SELLで有効な数量をプロンプトに具体的に書くようにしました。
def _sell_quantity_guidance(held: dict[str, Any]) -> str:
qty = held.get("qty")
if not isinstance(qty, int):
return ""
valid_qtys = [q for q in range(LOT_SIZE, qty + 1, LOT_SIZE)]
if not valid_qtys:
return (
f"SELLの場合、保有株数は{qty}株で単元株({LOT_SIZE}株)に満たないため、"
"この銘柄はSELLできません(SELLする場合はaction自体をHOLDにしてください)。\n"
)
if len(valid_qtys) == 1:
return (
f"SELLの場合、保有株数は{qty}株(ちょうど1単元)のため、"
f"部分的な売却はできません。proposed_qtyは{qty}(全株売却)のみが有効です。\n"
)
return (
f"SELLの場合、proposed_qtyは保有株数({qty}株)以下かつ単元株({LOT_SIZE}株)の"
f"倍数にしてください(有効な値: {', '.join(str(q) for q in valid_qtys)})。"
f"それ以外の数量(例: 保有数の半分)は単元に合わず発注できません。\n"
)あわせて、数量が0株に切り捨てられて発注を見送った場合は、スケジューラのログに残すようにしました。DBを直接調べなくても、ログを見れば「判断はあったが数量の問題で発注されなかった」ことが分かります。
教訓: 「何もしない」という結果にも、理由の記録を残す。何も起きていないことは、ログが無いと正常と見分けがつきません。
3. 株価の取得に失敗した銘柄が、評価額0円になっていた
症状
2026年9月18日のトレード日誌と運用状況ページで、保有していた「いちご(2337)」の評価額が0円、含み損益が-100%と表示されていました。銘柄が突然無価値になったわけではありません。総資産は実際より約40,800円少なく計算されていました。
原因
引け後に日次の集計を作る処理で、保有銘柄の現在値を取得する部分が次のようになっていました。
# 修正前(イメージ)
price = broker.get_current_price(symbol) or 0.0get_current_price()は、kabuステーションが起動していない、レート制限にかかった、ネットワークが一瞬切れた、といった一時的な失敗をすべて区別せず、Noneを返す設計です。1銘柄の失敗で全体を止めないための設計でしたが、呼び出し側のor 0.0によって、「取得できなかった」が「0円だった」に化けていました。
その結果、実在しない大損失が実績として記録され、公開されてしまいました。このブログの「実際の結果をそのまま公開する」という方針からすると、一番避けたい種類の間違いです。
修正
取得に失敗したときは、0円ではなく取得平均単価で代用し、代用したことをログに残すようにしました。
def _price(symbol: str) -> float:
if symbol not in price_cache:
live_price = broker.get_current_price(symbol)
if live_price is None:
avg_cost = raw_positions[symbol][1]
logger.warning(
"現在値の取得に失敗したため平均取得単価で代用します: symbol=%s avg_cost=%s",
symbol,
avg_cost,
)
live_price = avg_cost
price_cache[symbol] = live_price
return price_cache[symbol]取得単価で代用すると含み損益は0円になるので、実態と完全に一致するわけではありません。それでも、「まだ持っている銘柄が突然無価値になった」という明らかな誤りは避けられます。9月18日分のトレード日誌と運用状況ページのデータは、正しい値(総資産306,245円)で作り直しました。
教訓: 「値が無い」を表す代わりの値に、意味のある数字(特に0)を使わない。0円は「取れなかった」ではなく「価値が無い」という主張になります。
4. レート制限でTOPIXの記録が1日分抜けた
症状
9月24日の総資産グラフで、比較用に表示しているTOPIXの線だけが、その日の点を欠いていました。
原因
引け後の日次処理(16:00)では、保有銘柄・日経平均・TOPIXの板情報を短い間隔で続けて取得します。このうちTOPIXの1回が、kabuステーションからHTTP 429(リクエストが多すぎる)を返されました。3の件と同じく、get_current_price()はこの失敗を受け止めてNoneを返し、その日のTOPIXの値は記録されないまま処理が進みました。
修正
板情報の取得で429が返ってきたときだけ、1秒・2秒・4秒と間隔を空けて再試行するようにしました。429以外のエラーは再試行せず、そのまま失敗として扱います。
BOARD_RETRY_DELAYS_SECONDS = (1.0, 2.0, 4.0)
def _get_board_response(self, symbol: str) -> httpx.Response:
response = None
for delay in (*BOARD_RETRY_DELAYS_SECONDS, None):
response = httpx.get(
f"{self.base_url}/board/{symbol}@{TOKYO_EXCHANGE_CODE}",
headers=self._headers(),
timeout=10.0,
)
if response.status_code != 429 or delay is None:
break
logger.warning("kabuステーションが429を返したため%s秒待って再試行 symbol=%s", delay, symbol)
time.sleep(delay)
response.raise_for_status()
return response(*BOARD_RETRY_DELAYS_SECONDS, None)の最後のNoneは「もう待たない」の印です。3回再試行しても429なら、4回目の結果でraise_for_status()が例外を出します。9月24日分のTOPIXは、kabuステーションが返したその日の終値(4075.3)で補完しました。
教訓: 一時的な失敗には再試行を、それ以外の失敗には記録を。すべての失敗を同じNoneで受け止めると、再試行すれば取れたはずの値まで失います。
5. 約定価格を取りに行くたびに、全注文の一覧を取得していた
症状
これは公開した数字の誤りではなく、直している途中で見つかったバグです。
発注直後はまだ約定価格が確定していないため、日次の集計では一時的に「AIが判断した時点の株価」で約定価格を近似していました。9月17日、この近似が原因で総資産の表示が実際より605円ずれていることが分かり、kabuステーションから実際の約定価格を取得して置き換える処理を追加しました。
ところが、過去の注文分をまとめて実際の約定価格に置き換えようとしたところ、kabuステーションから429(レート制限)が返ってきました。
原因
注文1件の約定価格を調べる関数が、内部で全注文の一覧を取得するエンドポイント(/orders)を呼び、その中から該当する1件を探していました。まだ約定価格を反映していない注文が10件あれば、全注文の一覧を10回取得することになります。いわゆるN+1問題です。
修正
全注文の一覧は1回の呼び出しですべての注文を返すので、1回だけ取得して、注文IDをキーにした辞書にしてから手元で引く形に変えました。関数も「1件の約定を返すget_order_fill(id)」から「全件の約定を辞書で返すget_order_fills()」に作り替え、呼び出し側は辞書を引くだけにしています。
教訓: 「1件取得する関数」の中身が「全件取得して探す」になっていないか確認する。ループの中で呼ぶと、そのまま呼び出し回数が件数倍になります。
5つのバグに共通していたこと
| バグ | 失敗の吸収のしかた | 結果 |
|---|---|---|
| 1. 90日前のデータ | 日付の無い値をそのまま渡す | AIが誤った前提で判断 |
| 2. SELLが0株に | 0株なら何も記録せず終了 | 判断が黙って消える |
| 3. 評価額0円 | Noneをor 0.0で0円に | 架空の損失を公開 |
| 4. TOPIXの欠落 | 429をNoneとして吸収 | その日の記録が欠ける |
| 5. N+1の問い合わせ | (設計の問題) | レート制限にかかる |
5つのうち4つは、失敗や異常を「止まらずに先へ進める」書き方の副作用でした。止まらないことを優先した設計自体は、無人で動かすシステムとして間違っていないと思っています。ただ、止まらないのであれば、代わりに使った値が本当に無害か、そして代わりの値を使ったことが後から分かるかの2点を、失敗を吸収するすべての箇所で確認する必要がありました。
よくある質問
Q. テストを書いていても、これらのバグは防げなかったのですか? A. テスト自体は書いていましたが、防げませんでした。90日遅延のデータに日付が付いていない問題や、kabuステーションが短時間の連続リクエストで429を返す挙動は、実際のAPIと実データで動かして初めて表に出たものです。見つかった後は、同じ状況を再現するテストを追加しています。
Q. 間違った数字を公開してしまった記事はどうしましたか? A. 評価額0円の件では、その日のトレード日誌と運用状況ページのデータを正しい値で作り直しました。TOPIXの記録が欠けた日も、kabuステーションが返したその日の終値で補完しています。
Q. エラーで止めるのと、黙って代わりの値を使うのと、どちらがいいのですか? A. 場合によります。このシステムでは、1銘柄の取得失敗で全体を止めないために、失敗を吸収する設計自体は残しています。変えたのは、吸収するときの代わりの値(0円ではなく取得単価)と、吸収したことを必ずログに残す点です。
まとめ
実際のお金とAPIで動かし始めてからの2週間で見つかったバグは、どれもエラーでは止まらず、間違った結果を出し続けるタイプでした。失敗を吸収するコードを書くときは、代わりの値が意味を持ってしまわないか、そして吸収したことが記録に残るかを確認する。この2つを、今はコードレビューの観点として意識しています。
システムの判断ロジック全体はAI日本株自動売買の判断ロジックとリスク管理ルールで、発注に使っているAPIについてはkabuステーションAPIの記事でまとめています。
免責事項
この記事は投資助言ではありません。特定の銘柄や取引手法を推奨するものではなく、AIに実際のお金を運用させる実験の実装内容を記録したものです。売買の判断や結果を、他の方の投資判断の根拠として参照しないようご注意ください。投資は自己責任で行ってください。
AI日本株トレード実験