EDINET DBを作る過程で、有価証券報告書のXBRLデータと格闘した。3,848社×最大6年分の財務データを、会計基準をまたいで名寄せする。
やってみてわかったのは、XBRLは「構造化データ」と謳っているわりに、実運用上は半構造化データに近いということだ。会計基準が3種類あり、企業独自の拡張タクソノミがあり、連結と単体が混在している。
この記事では、名寄せエンジンの設計と、データ品質を担保するために組んだ30ルールの中身を書く。公開ドキュメント(edinetdb.jp/docs/data-quality)の裏側にある、実装上の判断とハマりポイントの記録だ。
問題の構造
「売上高」をAPIで返したい。それだけのことに、これだけの障壁がある。
会計基準ごとにelement_idが違う
JP GAAP(2,933社):
jpcrp_cor:NetSalesSummaryOfBusinessResults
IFRS(295社):
jpcrp_cor:RevenueIFRSSummaryOfBusinessResults
US GAAP(8社):
jpcrp_cor:RevenueUSGAAPSummaryOfBusinessResults
これは公式タクソノミに定義された標準element_idの話で、ここまではドキュメントを読めばわかる。
企業が独自拡張したelement_idがある
IFRS企業の一部は、標準element_idではなく独自拡張を使う。
jpcrp030000-asr_E02144-000:RevenueFromContractsWithCustomers...SummaryOfBusinessResults
これはトヨタの例。jpcrp030000-asr_EXXXXX というプレフィックスで、企業ごとに命名規則が異なる。標準マッピングでは捕捉できない。
連結と単体が混在している
同じelement_idでも、context_idによって連結か単体かが変わる。
context_id: CurrentYearDuration → 連結
context_id: CurrentYearDuration_NonConsolidatedMember → 単体
さらに、配当金や資本金は逆で、NonConsolidatedMemberの方が正しい値になる。一律に「NonConsolidated除外」とやると、配当金が取れなくなる。
2層構造の名寄せエンジン
この問題を、Layer 1(標準マッピング)とLayer 2(正規表現フォールバック)の2層で解決した。
Layer 1: 141パターンの標準マッピング
24の財務指標について、3会計基準×複数のelement_idバリエーションをハードコードしたマッピングテーブルを作った。
FINANCIAL_ITEM_MAP = {
# 売上高
"jpcrp_cor:NetSalesSummaryOfBusinessResults": ("revenue", "売上高"),
"jpcrp_cor:RevenueIFRSSummaryOfBusinessResults": ("revenue", "売上高"),
"jpcrp_cor:RevenueUSGAAPSummaryOfBusinessResults": ("revenue", "売上高"),
# 営業利益
"jppfs_cor:OperatingIncome": ("operating_income", "営業利益"),
"jpigp_cor:OperatingProfitLossIFRS": ("operating_income", "営業利益"),
# ...全141パターン
}
指標は売上高、営業利益、純利益のようなP/L項目に加えて、総資産、純資産のB/S項目、営業CF/投資CF/財務CFのCF項目、EPS、BPS、PER、配当金など合計24指標。
ここで1つ地雷があった。営業利益のelement_id問題だ。
JP GAAPでは、SummaryOfBusinessResults(経営指標等の推移)に営業利益の公式element_idが存在しない。代わりにP/L本表のjppfs_cor:OperatingIncomeを使う必要がある。これは他の指標と取得元が異なるため、優先順位の制御が必要になる。
実装では、Key Financial Data(KFD)を最優先で採用し、次に会計基準に一致するelement_idを選ぶという優先度ベースの値選定ロジックを組んだ。
Layer 2: 正規表現フォールバック
Layer 1では約95%のデータをカバーできるが、残り約5%の企業固有拡張element_idを取りこぼす。IFRSのトヨタやソニーなど、大型銘柄がここに含まれるので無視できない。
# 売上高の正規表現
revenue_regex = r'(?i)(NetSales|Revenue|OperatingRevenue|BusinessRevenue|GrossSales).*(?:SummaryOfBusinessResults|KeyFinancialData)'
revenue_exclude = r'(?i)(Intersegment|Cost|Gain|Loss|Allowance|Commission|Refund|Proceeds)'
# 営業利益の正規表現
oi_regex = r'(?i)(OperatingProfitLoss|OperatingIncome|OperatingProfit|BusinessProfit|CoreOperatingIncome).*(?:SummaryOfBusinessResults|KeyFinancialData)'
# 純利益の正規表現
ni_regex = r'(?i)(?:^|:)[^:]*Profit.*SummaryOfBusinessResults'
ni_exclude = r'(?i)(Ordinary|Operating|BeforeTax|GrossProfit|Equity|Earnings|Business)'
ポイントは除外パターンの方にある。「Revenue」にマッチするelement_idは大量にあるが、セグメント間売上、売上原価、売却益なども含まれる。除外条件を厳密に定義しないと、全く別の数字を売上高として返すことになる。
純利益は特に罠が深い。ProfitSummaryOfBusinessResultsにマッチさせたいが、OrdinaryProfit(経常利益)やOperatingProfit(営業利益)やGrossProfit(売上総利益)も全部Profitを含む。除外パターンで7種類を排除してようやく純利益だけが残る。
2パスの連結/単体フォールバック
context_idの処理は2パス構成にした。
Pass 1: 連結データを取得する。NonConsolidatedとMemberを含むcontext_idを除外。ただし配当金・資本金は例外で、NonConsolidatedMemberから取る。
Pass 2: Pass 1で値が取れなかった企業(約525社)について、単体データにフォールバックする。NonConsolidatedMemberのcontext_idから値を取得。
ただし全指標をフォールバックするわけではない。EPSと設備投資額と従業員数はフォールバック対象外にしている。連結EPSと単体EPSは意味が違うし、混在させるとスクリーニングの精度が落ちる。
-- EPS, capex, num_employeesはPass 2から除外
AND b.itemName NOT IN ('eps', 'capex', 'num_employees')
30ルールの品質ロジック
名寄せでデータを統一しても、値そのものがおかしいケースがある。単位誤り、株式分割による非連続、業種固有の開示特性。これらを4段階のパイプラインで処理する。
STEP 1: Bronze → Silver(16ルール)
入力フィルタリングとLayer 1/2の名寄せ。ここまでに書いた内容がこのステップに相当する。
- マッピング済み141element_idのみ通過
- 数値型データのみ抽出
- 過去5年度分に限定
- 孤立行(企業コードor決算期がNULL)を排除
- Layer 1/2の名寄せ適用
- 2パス連結/単体フォールバック
STEP 2: Silver → Gold集計(1ルール)
行持ちデータをピボットして、1企業×1年度=1行の横持ちに変換する。24指標が列になる。
STEP 3: 比率算出とサニタイズ(11ルール)
ここが一番地雷が多い。
EPS異常値の検出:
CASE
WHEN ABS(eps) > 1000000 THEN NULL -- 単位誤り(100万円超はありえない)
WHEN raw_prev_eps IS NOT NULL
AND ABS(SAFE_DIVIDE(eps, raw_prev_eps)) > 10 -- 前年比10倍以上は株式分割
THEN NULL
ELSE eps
END AS eps
EPSが100万円を超えるのは、ほぼ確実に単位誤り(円単位のところに千円単位で入っている等)。前年比10倍以上の変動は株式分割or株式併合の影響で、時系列比較に使えないからNULL化する。
BPSも同様のロジック:
CASE
WHEN ABS(bps) > 10000000 THEN NULL -- 1,000万円超
WHEN raw_prev_bps IS NOT NULL
AND ABS(SAFE_DIVIDE(bps, raw_prev_bps)) > 10
THEN NULL
ELSE bps
END AS bps
ROEの債務超過ガード:
CASE
WHEN netAssets < 0 THEN NULL -- 純資産がマイナス → ROE計算が意味をなさない
ELSE SAFE_DIVIDE(netIncome, netAssets)
END AS roe
純資産がマイナス(債務超過)の企業でROEを計算すると、赤字なのにROEがプラスになるという逆転が起きる。会計的に無意味なので排除。
保険業・銀行業のクロススコープガード:
CASE
WHEN industry IN ('保険業', '銀行業')
AND netIncome > 0 AND netIncome > revenue
THEN NULL
ELSE SAFE_DIVIDE(operatingIncome, revenue)
END AS operatingMargin
保険持株会社は「売上高」の定義が一般事業会社と異なる。連結純利益が連結売上高を上回るケースがあり、これは開示スコープの不一致を示すシグナル。営業利益率をNULLにして誤表示を防ぐ。
配当性向のキャップ:
CASE
WHEN eps > 0 AND dividendPerShare > 0
AND SAFE_DIVIDE(dividendPerShare, eps) <= 2.0 -- 200%超はNULL
THEN SAFE_DIVIDE(dividendPerShare, eps)
END AS payoutRatio
EDINETのDPS(1株当たり配当金)は株式分割を遡及調整しない。過去のDPSが見かけ上膨れて、配当性向が1000%になるケースがある。200%を上限としてそれ以上はNULL化。
STEP 4: モニタリング(2ルール)
Transform実行後に、4つのコア指標(revenue, net_income, total_assets, net_assets)の欠損を自動検知する。
# 欠損企業をWARNINGログに出力
logger.warning(
"DATA GAP DETECTED: %s (%s, %s) missing: %s",
row.filerName, row.edinetCode, row.accountingStandard,
list(row.missing_items)
)
NULL率が20%を超える指標は警告、30%を超えると致命的アラートを出す。現状、検知される欠損企業はかんぽ生命(保険持株会社で売上高なし)、サンバイオ(開発ステージで売上なし)など、開示形式に起因する正常な欠損ばかりだ。
カバレッジの結果
この30ルールで、以下のカバレッジを達成した。
| 指標 | カバレッジ | 取得社数 / 全体 |
|---|---|---|
| 売上高 | 99.6% | 3,831 / 3,845 |
| 営業利益 | 97.2% | 3,736 / 3,845 |
| 純利益 | 99.9% | 3,840 / 3,845 |
| 総資産 | 99.9% | 3,841 / 3,845 |
営業利益の97.2%は他より低いが、内訳を見ると銀行83社、保険10社、IFRS総合商社5社、US GAAP企業6社など、制度的にその指標を開示しない企業が大半。取りこぼしではなく正常な欠損だ。
設計判断のまとめ
-
ハードコード141パターン+正規表現フォールバックの2層構造: 安定性と網羅性のバランス。新しい企業や会計基準変更があってもLayer 2で吸収できる
-
2パス連結/単体フォールバック: 単体決算のみの企業もカバーしつつ、連結/単体の混在を防止。指標によってはフォールバックしない制御が必要
-
サニタイズは閾値ベースでNULL化: 怪しいデータは「直す」のではなく「消す」方針。間違った数字を返すよりNULLの方が害が少ない
-
業種固有の例外処理: 一律ルールだけでは保険・銀行・IFRSで破綻する。業種コードによる分岐は避けられない
ロジックの全文は公開ドキュメントに記載している。
- 指標定義: edinetdb.jp/docs/metrics
- データ品質: edinetdb.jp/docs/data-quality
- 信用スコア: edinetdb.jp/docs/credit-scoring
EDINET DB: edinetdb.jp
APIキー発行(無料): edinetdb.jp/developers
Comments
Let's comment your feelings that are more than good