0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

EDINETのXBRLを自分でパースして絶望した話

0
Posted at

XBRLはXMLの方言なので、lxmlでパースすれば1日で終わると思っていた。

5日後、自分が何を知らなかったかを理解した。

この記事はEDINETの有報XBRLを全社分パースするシステムを作った記録。うまくいかなかった部分も全部書く。同じことをやろうとしている人が同じ穴に落ちないように。

Day 1: XMLでしょ?簡単でしょ?

EDINETから有報のZIPをダウンロードして展開すると、中にXBRLファイルがある。XMLだ。lxmlでパースして売上高のタグを探せばいい。30分で書ける:

from lxml import etree

tree = etree.parse("xbrl_file.xbrl")
# 名前空間を指定して売上高を探す
ns = tree.getroot().nsmap
revenue = tree.find(".//{*}NetSales")
print(f"売上高: ¥{int(revenue.text):,}")
# 売上高: ¥1,164,922,000,000

動いた。1社目は。

2社目を試す:

revenue = tree.find(".//{*}NetSales")
print(revenue)
# None

None

1社目はJP-GAAP。2社目はIFRS。売上高のタグ名が違う。JP-GAAPの売上高タグはIFRS企業には存在しない。

Day 2: 名前空間地獄

日本の上場企業は3つの会計基準で有報を提出する:

  • JP-GAAP(日本基準) — 約70%の企業
  • IFRS(国際会計基準) — 約25%
  • US-GAAP(米国基準) — 約5%

同じ「売上高」なのに、会計基準ごとにタグ名が違う。名前空間も違う。

しかも名前空間のURLにタクソノミーのバージョン年が含まれている。2023年提出と2024年提出でURLが変わる。ハードコーディングした名前空間はタクソノミー更新のたびに壊れる。

じゃあif文で3基準に対応すればいい?

# こうなっていく...
revenue = tree.find(".//{*}NetSales")          # JP-GAAP?
if revenue is None:
    revenue = tree.find(".//{*}Revenue")        # IFRS?
if revenue is None:
    revenue = tree.find(".//{*}Revenues")       # US-GAAP?
if revenue is None:
    # まだ他のパターンがある...
    pass

3パターンでは足りない。製造業、サービス業、銀行、保険会社でタグ名が違う。売上高だけで6パターン以上。これが52フィールドある。

動的に読み取る必要がある。だが話はまだ始まったばかり。

Day 3: 同じ概念、違うタグ、3会計基準

売上高だけではない。営業利益、純利益、総資産、ROE — すべての財務フィールドで同じ問題が起きる。基準ごとにタグ名が違う。

しかも1つの有報の中に、同じ値が複数の場所に出現する。サマリーセクションと財務諸表本体で名前空間が違う。どちらを取るか判断が必要。

さらに罠がある。企業が会計基準を移行した場合、有報の中に新旧両方の基準の要素が残っている。当期データは新基準、比較データは旧基準。両方が同じ概念にマッピングされる。基準を考慮した重複排除がないと、今年の売上高ではなく数年前のデータを取得してしまう。

日本最大級の企業で、この誤差は数十兆円になる。エラーなくパースが完了する有報から。

Day 4: 数値パースの闘

XMLだからint(element.text)で終わるでしょ?

int("1,234,567")      # ValueError: カンマ
int("-1,234,567")    # ValueError: 全角マイナス
int("△1,234,567")    # ValueError: 三角
int("▲1,234,567")    # ValueError: 黒三角
int("(1,234,567)")    # ValueError: 括弧
int("")              # ValueError: ダッシュ(意味: 該当なし)
int("")              # ValueError: 三点リーダ(意味: 同上)

マイナスの表現が5種類。「該当なし」を意味するダッシュ系文字が6種類以上。全角カンマと半角カンマの混在。すべての分岐は、実際にどこかの企業がそう提出したから存在する。設計時に想定したものは一つもない。

インラインXBRL(iXBRL)にはさらに罠がある。HTMLに表示されるテキストと、実際の値が異なるケース。スケーリング属性を見落とすと、データベースの全数値が100万分の1になる。既存のパーサーでこのバグを見たことがある。

古い有報はShift-JISエンコーディング。2015年以前のデータを扱うなら避けられない。

Day 5: これはアプリじゃない、インフラだ

ここまでで、1社のXBRLを正しくパースできるようになった。

問題は規模。

  • 11,000社以上 × 15年分 × 3会計基準 × 2フォーマット(従来XBRL + iXBRL)
  • 数百のXBRL要素を52の正規化フィールドにマッピング
  • 連結と個別(非連結)の判別
  • 30分ごとの新規提出チェック

1社のスクリプトを書くのと、全社が確実に動くシステムを作るのは全く別のエンジニアリング。

コンテキスト解決

すべてのXBRL値にはコンテキスト識別子がある。1件の有報に同じ要素が10以上のコンテキストで出現する — 当期連結、当期個別、前期、セグメント別、地域別…

間違ったコンテキストを取ると間違った数値になる。日本最大級の企業で連結と個別の差は10兆円を超える。

正しいコンテキストを選ぶ優先順位ロジックを構築するまでに、生の有報を数百件読んだ。しかもそのロジックは一律ではない — 連結決算を作成しない単体決算企業では、いわゆる「個別」データが唯一の正しいデータになる。一律に除外すると、11,000社のうち7,000社以上がゼロになる。

タクソノミーの継続的メンテナンス

金融庁が公開するXBRLタクソノミーは毎年更新される。要素名が変わる。要素が非推奨になる。新しい要素が登場する。

1つの財務概念に対して、会計基準ごと × 業種ごとに複数のバリエーションがある。製造業とサービス業と銀行と保険会社で、同じ「売上高」のタグ名が違う。

新しいフィールドを追加するたびに、3基準 × 複数の業種バリエーション × 15年分の過去タクソノミーに対してマッピングを確認する必要がある。

作ったもの

結局、パーサーは約900行のPythonになった。LLMなし、機械学習なし。lxmlとタクソノミーマッピングだけ。

  • 数百のXBRL要素 → 52の正規化フィールド
  • 3つの会計基準(JP-GAAP / IFRS / US-GAAP)
  • 2つのXBRLフォーマット(従来XBRL + iXBRL)
  • 連結 / 個別の自動判定
  • 会計基準移行を考慮した重複排除

テストは4,800行以上。パーサーの5倍。XBRLは正しい表現より間違った表現の方が多いので、テストの方が長くなる。

665の自動正確性チェック — すべて本番で壊れた実際のバグから再構成したシナリオ。テストは単調増加。まだ一つも削除していない。

カバレッジ:

フィールド 取得率
売上高 99.6%
純利益 99.4%
総資産 99.5%
営業利益 97.2%

残りの0.4%〜2.8%は、非標準的なXBRLを提出している企業。たとえばマイナーな独自拡張タクソノミーを使っている場合。

やり直すなら

iXBRLから始める。 最近の提出の約60%がiXBRL。古い有報のために先に別のアプローチで作ったが、逆順のほうが効率的だった。

マッピングの自動化に早く投資する。 最初の1週間はXBRL要素を手動で集めようとした。公式タクソノミーから自動抽出するパイプラインを先に作れば数日節約できた。

エッジケースのログをもっと。 想定外のコンテキストや欠損フィールドの警告を出す仕組みは、本番障害のデバッグ後に追加したものが常に一番役立った。最初から入れておけばよかった。

自分でパースしなくていい方法

このパーサーの結果をAxioraというAPIで公開している。約900行のパーサーが裏で動いて、3行で結果が返る:

from axiora import Axiora

client = Axiora()  # 環境変数 AXIORA_API_KEY から読み取り

# IFRS企業もJP-GAAP企業も同じフィールド名
toyota = client.companies.retrieve_financials("7203", years=1)
print(f"トヨタ: ¥{toyota.data[0].revenue:,}")
# トヨタ: ¥48,036,704,000,000

keyence = client.companies.retrieve_financials("6861", years=1)
print(f"キーエンス: ¥{keyence.data[0].revenue:,}")
# キーエンス: ¥1,059,145,000,000

会計基準が違っても同じ.revenueフィールド。スケーリングもコンテキスト解決も数値パースも裏側で処理済み。11,000社以上、14万件以上の有報。

無料プラン。クレカ不要。1日1,000リクエスト。

参考

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?