高速な XBRL パーサーを Python で書く

必要なことを必要なだけ。どうも、かわしんです。

前回の記事では、AI を使って作ってきた日本の上場株式銘柄解析システムのアーキテクチャについて解説しました。

kawasin73.hatenablog.com

今回は、銘柄解析の肝となる XBRL パーサーである Arelle が遅かったので、Python で自前の高速なパーサーである xbrlp を作って 20 倍速くした話をします。

9 月上旬当時の Claude Code, Codex にはまともな効率の良いプログラムを書くことができなかったのでこのパーサーのコアの部分は自分で書いています。

ソースコードは単一の Github リポジトリにはなっていないので、gist にあげておきました。この記事の一番下に埋め込んでいます。

XBRL parser · GitHub

こんな感じで使います。

gist.github.com

XBRL とは、決算報告や財務諸表をプログラムで解析しやすく設計された XML ベースのフォーマットです。日本では、上場企業は金融庁が管理する EDINET に有価証券報告書をアップロードすることが義務付けられており、EDINET の閲覧サイト では無料で過去 10 年分のアップロードされた XBRL ファイルをダウンロードすることができますし、pdf ファイルなどでの閲覧もできます。また、無料の API 登録をすることで API 経由で過去の XBRL ファイルをダウンロードすることもできます。

また、四半期の決算発表で公表される決算短信XBRL ファイルも東証にアップロードされ、TDNet の適時開示情報閲覧サービス で無料でダウンロードできます。TDNet の API は有料ですが、日次の一覧も銘柄ごとの一覧も Web ベースのシステムで無料で閲覧し XBRL ファイルをダウンロードすることができます。邪推ですが、おそらく TDNet の無料の閲覧システムはリアルタイム性や信頼性への保証がないから有料版の API と差別化されているのだと思います。多分。

上場企業の財務諸表を手に入れたいのであれば、yfinance を使うのが無料で使えるメジャーな手法だと思います。しかし、ネットネットバリュー株投資をする上ではいかに詳細な資産の項目を取るかが重要なので、1 次情報である XBRL ファイルを直接パースして柔軟にデータの抽出を行うことにしました。

例えば、7058 共栄セキュリティーサービス は、固定資産として「金地金」を 10 億円分保有していますが、yfinance では一般的な企業を前提にして正規化しているため、金地金のデータは無視されています。

とはいえ、yfinance は十分精度高くデータの抽出と正規化をしているので、一般的な解析をするには yfinance のデータで十分だと思います。

PythonXBRL パーサーとしては、Arelle が有名です。初めは Arelle を使ってデータの抽出を行っていましたがとても遅いです。

このツイートにもありますが、有価証券報告書をひとつパースし終わるまでに 5 秒くらいかかります。4000 銘柄が毎年 4 回四半期と通期の決算を報告するので 1 年分だけで 16000 ファイルありますし、マルチプロセスで並列に動かしても1年分を全てパースするのに一晩かかります。それを 10 年分パースして EDINET と TDNet それぞれでパースすると考えると全てパースするのに1週間くらいかかってしまいます。データ抽出ロジックを都度改良する度にその変更をデータベースに反映するのに 1 週間かかるのは流石にしんどいので最適化を考えました。

Claude Code に Arelle のパース中のプロファイルを取らせてなぜ遅いのか調査させると、メタデータの構築とそのバリデーションに CPU 時間を食われているということでした。しかし、自分のデータ抽出にはバリデーションは不要ですし、利用するメタデータも一部のみです。例えば、データを抽出する時は QName のみを使って要素を判別するので日本語や英語のラベルは必要ありません。また、要素同士の関係性を表すリンクベースには、calculationLinkbaseRefdefinitionLinkbaseRefpresentationLinkbaseRef などがありますが、実際に利用するのは 1 つのみで、他のリンクベースは必要ないです。

Arelle はあらゆるユースケースに対応するために最初の読み込み時に全てのデータを読んでモデル構築をするので、自分のデータ抽出に必要ないデータ読み込みをスキップすることで高速化ができそうですが、Arelle にはそういう最適化が可能な API がないため、自分で 1 から XBRL パーサーを書くことにしました。

最初は Rust で書いて Python バインディングを提供しようとしていましたが、インストールが煩雑になってしまうし、XML パーサーが Rust の標準ライブラリになかったため、Python で書くことにしました。Arelle の遅さが言語由来ではなく無駄な処理が多いためだったというのも理由です。

大きく 2 つの処理をスキップすることで速くします。すでにパースされたメタデータファイルの読み込みのスキップと不要なリンクベースファイルの読み込みのスキップです。

XBRL は数値データが埋め込まれた HTML である本文の -ixbrl.htm ファイルとメタデータ構造を定義する複数の XML ファイルで構成されます。メタデータファイルにも、要素同士の関係を表すリンクベースファイル (_cal.xml, _def.xml, _pre.xml, _lab.xml) や、どのようなメタデータファイルがあるかをリストして、本文に含まれうる要素を列挙するスキーマファイル (.xsd) ファイルがあります。スキーマファイルは import タグによって複数のスキーマファイルを再帰的に読み込むこともできます。

共通したスキーマファイルのキャッシュ

メタデータファイルには会社ごとの XBRL ファイル群に含まれるローカルのファイルと、EDINET などがリモートサーバーから HTTP 経由で提供するファイルがあります。リモートのスキーマファイルのパース結果は同じになるため会社ごとにパースする必要はありません。リモートのスキーマファイルのパース結果をメモリ上にキャッシュすることで、複数の XBRL ファイルをバッチでパースする時に重複するパース処理をスキップすることができます。

また、リモートのファイルはローカルのファイルシステムにダウンロードしてキャッシュし不要なネットワークアクセスを防ぐようにしました。一度読み込みを行った XBRL ファイルについては再読み込み時にはネットワークアクセスが発生しません。

必要なデータのみの読み込み

Parser クラスは必要なデータのみを読み込むメソッドを明示的に提供し、ユースケースごとに不要になるデータの読み込みをユーザーが防ぐことができるようにします。

  • load_facts(): 本文中の , タグに埋め込まれたデータを読み込んで返します。
  • load_presentation_links(): 表示上の親子関係を表すリンクベースを返します。
  • load_calculation_links(): 計算上の親子関係を表すリンクベースを返します。
  • load_labels(): 全要素のラベルを返します。

データの抽出のみを行うときは load_calculation_links()load_facts() を使い、不要なラベルや _pre.xml の読み込みコストをスキップできるようにします。

Zero Dependency

僕は Zero Dependency 過激派なので、自分が作るライブラリでは依存する third party ライブラリを最小限にします。依存するライブラリが増えれば増えるほどソフトウェアの品質を落とします。Python には XML パーサーが標準ライブラリにあるので僕のパーサーは全て標準ライブラリのみで作っています。外部ライブラリをインストールしなくても使えるので、ポータビリティが高くなります。

メモリ効率の最適化

僕はいつも メモリアロケーションに対する罪悪感 を持っているのでメモリの使い方には気を使います。

データやリンクベースの読み込み API では、結果をリストではなくイテレータで返します。要素数やリンク数はかなり大量になるため、リストにまとめてから返すと一時的なメモリ消費量が大きくなってしまいます。イテレータにすることで一時的なメモリ使用量のスパイクを抑えることができます。また、必要なデータが途中までで全て読み込めた場合は読み込みを途中で中断することもできます。

文字列の結合は、毎回メモリアロケーションと文字列のコピーが発生するためなるべく最小限にします。Python の標準ライブラリの XML パーサーではタグ名などをネームスペースを解決した状態で出力します。例えば、ix:nonFraction{http://www.xbrl.org/2008/inlineXBRL}nonFraction と出力されます。ネームスペースのマッピングを管理してタグを比較することが必要なのですが、要素ごとに ix:nonFraction のタグを URI 埋め込みのものに変換するのは文字列結合のコストがかかり無駄なので、タグ名変換の文字列結合はネームスペースが検出された時にまとめて行いキャッシュして使い回すようにします。

本当はファイルからバッファに読み込まれた XML をゼロコピーでパースするのが理想ですが、標準ライブラリの XML パーサーは対応していません。また、要素ごとに前述のネームスペース解決をしているので効率が悪いです。xml.etree.ElementTree 以外にも xml.saxxml.parsers.expat が標準ライブラリにはありますが、いずれもイテレータにすることができないため、諦めて標準ライブラリ由来の非効率性については許容することにしました。

必要なことを必要なだけ

Simple Made Easy でも紹介されている通り、効率の良いライブラリは Simple であることを目指すべきです。Arelle は Easy であるため、初心者でも使いやすいですが遅いです。

効率の良いライブラリはシンプルで小さな責務を果たすために必要な最小限の機能を提供し、ユーザーはそれを組み合わせることで様々なユースケースに対応します。上記の “必要なデータのみの読み込み” で説明したようにそれぞれの読み込みメソッドは対応するファイルを上から順に読み込んで必要な情報をイテレータで返すだけのシンプルな処理のみを行います。リンクベースからのグラフの構築などはライブラリユーザーの責務になります。これにより、不要な処理をしない効率良く使い勝手の良いプログラムを書くことができるようになります。

また、Fact ではそれぞれの要素の値のパースを遅延させます。例えば、千円単位の要素の 1,234 という文字列は 1234000 という数値に変換されるべきですが、xbrlp では各要素の Fact.value が呼ばれるまで変換はしません。これは、大部分の要素がデータ抽出には無関係で qname によるフィルタリングでどうせ弾かれるためです。必要のないデータのパースは行わずできるだけ生データのまま持ち回って、必要なデータを必要なだけ処理するようにして効率化します。

テスト

xbrlp が正しく全てのデータをヌケモレなくパースしているかどうかを確かめるために、ゴールデンテストを用いて検証しています。過去 10 年のそれぞれの年について JP GAAP, US GAAP, IFRS などの会計方法の違う実際の XBRL ファイルを複数ファイル選び Arelle を使ってデータ抽出してゴールデンファイルを作っています。

ix:nonNumeric に含まれる HTML データが標準ライブラリの xml パーサーによってパースされてしまい、元の生 HTML 文字列を復元できないという違いが発見されましたが、標準ライブラリを使う限りは避けられないので、正規化された後の XML 文字列が一致することを確認してヨシとしています。

正直、標準ライブラリの xml パーサーは余計なことを色々しているので効率が悪く、Simple ではなく Easy よりだなという印象です。

自作の xbrlp パーサーによって XBRL ファイルのデータ抽出が大体 20 倍くらい速くなりました。それまでは1年分の XBRL ファイルを全て処理するのに一晩かかっていましたが、10 分で終わるようになりました。1週間かかる 10 年分のデータ抽出やりなおしも一晩で終わります。

また、速いプログラムはただ速いというメリットだけでなく、力づくで全部の処理をやり切るという選択肢を可能にすることでワークフローに大きな影響を与えることができます。それまではデータ抽出ロジックを改良した後にデータベースをアップデートするのに時間がかかるのでどのようにデータを壊さずに差分更新するかということに腐心していましたが、一晩で全データを再処理し切れるのであればシンプルにデータを1から作り直すという選択肢が取れるようになりました。

速いは正義です。

以下はおまけです。

gist.github.com




元の記事を確認する

関連記事