どうも、おじいです。平日夜と休日に、AI エージェントとか自動売買 bot をちまちま作ってる 38 歳。
今日は、副業で開発してる企業分析 bot でやらかした、結構えぐいバグの話。動いてるように見えて、実は大事なデータをごっそり見逃してた。データ処理系の個人開発やってる人には、あるあるかもしれない。
何が起きたか
作ってる bot の一つに、米国上場企業の年次報告書(SEC ファイリング)を読み込んで、事業リスクを LLM で要約・評価するやつがある。企業の健全性をざっくり把握するためのツールだ。
こいつが、ある特定の企業群について、やけに「クリーン」な分析結果を出してくることに気づいた。「リスク要因: 特になし」みたいな。最初は「お、超優良企業か?」なんて思ってたんだけど、何社か続くとさすがにおかしい。そんなわけない。
ログを掘ってみたら、衝撃の事実が判明した。bot が、特定の企業の分析だけ「黙って」スキップしてた。エラーも吐かずに、ただ空っぽのデータを返してた。そのせいで、後段の LLM は「リスクに関する記述がなかった」と判断して、「リスクなし」という結論を出してたわけだ。これはやばい。
原因: 「10-K」と「20-F」という様式の違い
原因は、米国証券取引委員会(SEC)に提出される年次報告書の「様式」の違いにあった。
bot は当初、米国企業が提出する「Form 10-K」という書類だけを想定して作ってた。この 10-K では、事業リスクは「Item 1A. Risk Factors」という項目に記載されるのがお作法。だから、bot は機械的に「Item 1A」のセクションを引っこ抜くように実装してた。
ところが、分析結果が空になってた企業を調べたら、全部「外国企業」だった。米国市場に上場してる外国企業は、10-K の代わりに「Form 20-F」という別の様式で報告書を提出する。
そして、この 20-F では、リスク要因は「Item 3.D. Risk Factors」に書かれている。
つまり、bot は 20-F の書類に対して、存在しない「Item 1A」を探しに行ってた。当然、見つかるわけがない。
一番の問題は、その後の処理だった。セクションが見つからなかった時のエラーハンドリングを、こんな風に書いてた。
# 抽出失敗と「該当なし」を混同する危険なコード
try:
risk_text = doc.get_section("Item 1A")
except SectionNotFound:
risk_text = "" # これでは失敗したのか、元々空なのか分からない
一見、例外をキャッチしてて安全そうに見える。でも、これが罠だった。「セクションが見つからなくて抽出に失敗した」ケースと、「セクションはあったけど中身が空だった」ケースが、どっちも risk_text = "" になってしまう。
このせいで、データ欠損という重大な異常が、正常な「リスク記載なし」として処理され、静かにバグが進行してたわけだ。
修正: フォームタイプをちゃんと見て、失敗を区別する
対策はシンプル。
まず、処理対象のドキュメントが 10-K なのか 20-F なのかをちゃんと判別する。その上で、フォームタイプに応じて読み込むべきセクション ID を切り替えるようにした。
# フォームタイプに応じて処理を分岐する改善コード
if doc.form_type == "10-K":
section_id = "Item 1A"
elif doc.form_type == "20-F":
section_id = "Item 3.D"
else:
# 未対応のフォームタイプなら、処理を中断
log.warning(f"Unsupported form type: {doc.form_type}")
return None
# 抽出を試みる。失敗したらNoneが返るようにget_section側を修正
risk_text = doc.try_get_section(section_id)
if risk_text is None:
# セクション自体が見つからなかった場合(=異常)
log.error(f"Section not found: {section_id}")
# ... エラー処理 ...
elif risk_text == "":
# セクションはあったが空だった場合(=正常)
log.info(f"Risk factors section is empty: {section_id}")
get_section メソッドも修正して、セクションが見つからない場合は空文字じゃなく None を返すように変更した。
これで、呼び出し側は戻り値を見るだけで、
-
None→ セクションが見つからなかった(=想定外のエラー) -
""(空文字)→ セクションはあったが、中身が空だった(=データ上、リスク記載なし) -
(str)→ 正常に抽出できた
と、明確に区別できるようになった。この修正後、これまでスキップされてた外国企業の分析も、無事に動き出した。
学び: エラーを握りつぶすと、静かに死ぬ
今回の失敗から学んだことは2つ。
ドメイン知識はマジで大事。 今回で言えば、SEC ファイリングに 10-K と 20-F の違いがある、という知識。技術的な実装力だけじゃなくて、扱ってるデータそのものへの理解がないと、こういう根本的な見落としをする。特に金融系は、こういう「お作法」の塊みたいな世界。
「失敗」と「空」を区別する設計。 エラーを安易に握りつぶして、空文字や
0みたいな「無害そうな」デフォルト値を返すのは、本当に危険。サイレントなデータ欠損は、気づいた時には手遅れ、なんてこともあり得る。自動売買 bot なら、資産を溶かす原因に直結する。Noneを使ったり、専用の例外を投げたりして、異常は異常としてちゃんと後続の処理に伝える設計がいかに重要か、身をもって知った。
副業の個人開発だと、ついドキュメントを斜め読みして「動いたからヨシ!」で進めがちだけど、こういう地味な仕様の確認こそ、一番時間をかけるべき部分なのかもしれない。
この失敗ログが、同じようにデータ処理系の bot を作ってる誰かの参考になれば嬉しい。
I build and run small Python systems — trading bots, RAG APIs, scheduled automation — and write up whatever breaks along the way.
If a provider-agnostic RAG Q&A API is useful to you, mine is MIT-licensed on GitHub: rag-faq-api. It runs and passes its full test suite **with no API key* (offline stub LLM + hashing embedder), swaps to Claude / Gemini / OpenAI via one env var, and ships a retrieval-quality harness (Hit@k / MRR / Recall@k) with a chunking sweep.*
Top comments (0)