3行まとめ
- RakuScan(自分用の投資分析ツール)で月次のユニバース(スクリーニング対象銘柄群)再構築後、日次バッチが
Too many open filesで落ちた - 原因をたどると、本来200銘柄前後のはずのユニバースが679銘柄まで膨張していた。フィルタの「データ取得不可なら通過させる」という一行のコメントが、実質的にフィルタを無効化していた
- 原因を直しただけでは再発防止として不十分と判断し、「フィルタが何個あろうと銘柄数は絶対にこの数を超えない」という上限そのものをコードに埋め込んだ
RakuScanは複数の投資分析プラグインで銘柄をスクリーニングする個人用ツール。スクリーニング対象の銘柄群(ユニバース)は月次バッチで再構築し、日次バッチはそのキャッシュを使い回す設計になっている。ある月の月次再構築のあと、日次バッチがToo many open filesで落ちた。ここでは原因調査と、その直し方について書く。
症状:679銘柄、処理1時間超、そしてFD枯渇
ある月の月次ユニバース再構築のあと、日次バッチがファイルディスクリプタ不足で異常終了した。ログを遡ると、処理対象の銘柄数が想定よりはるかに多く、処理時間も1時間を超えていた。
config/universe.yamlのconstraintsには次のように銘柄数の目安が書かれている。
constraints:
target_size: 200 # 目標銘柄数
max_size: 250 # 上限
min_size: 100 # 下限(これを割ったらフィルタ緩和を検討)
目標200、上限250のはずのユニバースが、実際には679銘柄になっていた。
原因調査:時価総額フィルタの「Noneなら通過」
ユニバース構築はStep1(流動性フィルタ:ATR・出来高・時価総額・上場日数)→Step2(ファンダメンタルフィルタ)という順に銘柄を絞り込んでいく。Step1の時価総額チェックの実装が、修正前はこうなっていた。
# 修正前
market_cap_jpy = ticker_info.get("marketCap")
market_cap_billion: float = 0.0
if market_cap_jpy is not None:
market_cap_billion = market_cap_jpy / 1e8
if market_cap_billion < min_cap:
continue
# market_cap_jpy が None の場合はデータ取得不可として通過させる
意図としては「yfinanceから時価総額が取れない銘柄を、データ不備だけの理由で切り落とすのは厳しすぎる」という判断で、通過させる作りにしていた。一見、安全側に倒した配慮に見える。
ただyfinanceは、新規上場銘柄や情報の薄い小型株でmarketCapがNoneになるケースが少なくない。「取得不可なら通す」という一行が、実質的には時価総額フィルタそのものを素通りさせる穴として機能していた。該当する月はNoneになる銘柄がまとまって発生し、本来なら弾かれるはずの銘柄が雪崩的に通過して679銘柄まで積み上がった。
「データが分からないなら通す」が逆に効く場面
このバグの本質は、「不明なデータへの振る舞い」の選択がフィルタの目的と逆方向だったことにある。
ユーザー向け機能であれば「情報が足りないから機能を止める」より「とりあえず通して様子を見る」が親切な場合は多い。しかし選別・除外が目的のフィルタでは話が逆になる。「分からない」を「通す」にしてしまうと、フィルタ条件が厳しくなるほど「分からない」の割合が増えていき、フィルタの実効性がすり減っていく。基準を満たすと確認できたものだけを通す、という向きで設計しないと、データ欠損がそのまま抜け穴になる。
修正はこうなった。
# 修正後
market_cap_jpy = ticker_info.get("marketCap")
if market_cap_jpy is None:
logger.debug("除外(時価総額取得不可): %s", symbol_yf)
continue
market_cap_billion: float = market_cap_jpy / 1e8
if market_cap_billion < min_cap:
continue
Noneは「基準を満たすと確認できない」として除外側に倒す。たった数行の反転だが、フィルタの意味を保つには必須の修正だった。
根本原因を直すだけでは足りないと判断した理由
ここで直しただけでも、今回のケースは再発しなくなる。ただ、この修正だけで終わらせなかった。理由は、Step1・Step2のフィルタ群のどこにも、銘柄数の絶対的な上限を強制する仕組みがなかったことに気づいたからだ。
constraints.max_size: 250という設定値自体は以前から存在していたが、これは単なる設定値で、実際にコード側でそれを強制する処理はどこにもなかった。今回は時価総額フィルタの穴が原因だったが、仕組みとして「何らかのフィルタに将来別の抜け穴ができたら、また同じように銘柄数が膨張しうる」状態は変わっていない。
そこで、個別フィルタの修正とは別に、どのフィルタが何個あっても最終的な銘柄数は必ずmax_size以下になるという一段上のガードを追加した。
# Step 3 前: サイズ制約を適用(時価総額降順でトップ max_size 件に絞る)
constraints = cfg.get("constraints", {})
max_size: int = int(constraints.get("max_size", 250))
if len(stocks) > max_size:
stocks.sort(key=lambda s: s.market_cap_billion, reverse=True)
logger.info(
"サイズ制約適用: %d 銘柄 → %d 銘柄(時価総額上位 %d 件)",
len(stocks), max_size, max_size,
)
stocks = stocks[:max_size]
Step2までの結果が何件になろうと、ここで時価総額上位max_size件に強制的に絞り込む。個別フィルタのロジックが将来また壊れても、ユニバースのサイズという一番外側の制約だけは必ず効く。
「原因になった条件分岐を直す」のと「同じ壊れ方を二度とできないように上限を構造として埋め込む」のは、別の種類の対策になる。前者は今回のバグの再発を防ぎ、後者は次に起きる別のバグの被害範囲を制限する。どちらか一方では不十分だと判断して、両方を同じコミットに入れた。
症状そのものへの保険:ulimit引き上げ
原因と上限の対策は済んだが、Too many open filesという症状そのものへの保険も別途入れた。daily_run.shにulimit -n 4096を追加している。
# ファイルディスクリプタ上限を引き上げる(デフォルト256では多銘柄処理で枯渇する)
ulimit -n 4096
macOSのデフォルト上限は256と低く、yfinanceの通信やSQLiteのファイルアクセスが銘柄数分積み重なると、想定どおり200〜250銘柄に収まっていたとしても枯渇に近づく余地があった。ユニバースのサイズ上限を直したからもう安全、とは言い切れない薄い余裕だったので、根本原因とは無関係にこちらも引き上げておいた。
原因を直す・被害範囲に上限を設ける・症状自体への保険を積む、という3段構えになっている。どれか1つだけでは、同じ系統の障害か、似たような別の障害のどちらかにまた当たる可能性が残る。
まとめ
- 月次ユニバース再構築後に
Too many open filesで日次バッチが落ち、調査すると想定200〜250銘柄のはずが679銘柄まで膨張していた - 原因は時価総額フィルタの「データ取得不可なら通過させる」という一行。選別が目的のフィルタで「分からないものを通す」向きにすると、データ欠損がそのまま抜け穴になる
- 原因の修正(Noneを除外側に倒す)に加えて、個別フィルタの出来にかかわらず最終件数を強制的に絞る
max_sizeキャップを別途追加した。根本原因の修正と、被害範囲を構造的に制限する対策は別物として両方入れる判断をした -
ulimit -n 4096も合わせて追加し、原因とは無関係に症状自体への余裕も持たせた
RakuScanのデータ層設計・キャッシュ戦略・プラグインアーキテクチャについては、以下の連載・有料本でさらに詳しく解説している。
https://zenn.dev/sktt_panda/books/rakuscan-design-philosophy
この記事は Zenn にも同じ内容を投稿しています。