このプロジェクトについて
EDINET(有価証券報告書等)の開示データをもとに、日本株の財務指標・投資判断スコアを日次で算出・公開しているサービスです。
公開サイトはこちら →
https://stocks.happy-life-design.net
前回の記事はこちら →
https://qiita.com/rightcross-ae86/items/d16996ede05cc0afbad8
発端: 前回の修正を入れた翌日から、夜間バッチが3夜連続で落ちた
前回の記事で、個別財務諸表が実質空の持株会社について、連結側の数値を誤って個別値で埋めていた行を正しい値へ補正しました。その翌日から、日次メンテナンス処理のうち1株当たり純資産(BPS)を更新するタスクが、3夜連続で NumericValueOutOfRange で失敗するようになりました。
この会社のEDINET側の発行済株式数には、実際の株数ではなくプレースホルダとしての 1 が入っていました。自己資本を正しい大きな値へ補正した結果、自己資本 ÷ 発行済株式数 が自己資本そのものの値になり、格納先である financials.bps(numeric(12,4) = 整数部8桁)の上限を超えたことが直接の原因です。
実際に困ったのは、1行ではなく9,882行が止まっていたこと
問題は、この1行が計算できないことだけではありませんでした。BPSの更新は全社分を1本のUPDATE文で処理しています。SQLは文単位で成否が決まるため、1行で桁あふれが起きるとその文全体が失敗し、正常に計算できていた他社9,882行の更新も一緒に取り消されます。タスクとしては「BPS更新が丸ごと動いていない」状態が3夜続いていました。
対応①: 銘柄を名指ししない桁あふれガード
修正方針は2つありました。問題の1社を除外リストに加える方法と、計算結果が現実的な範囲を超えた行を一律で対象外にする方法です。プレースホルダ値が入るのがこの1社だけである保証はないため、後者を選びました。
SELECT f.id, ROUND((f.equity / s.shares_issued)::numeric, 0) AS bps
FROM financials f
JOIN shares s ON s.company_id = f.company_id AND s.fiscal_year = f.fiscal_year
WHERE f.period_type = 'annual' AND f.equity > 0 AND s.shares_issued > 0
+ AND ABS(f.equity / s.shares_issued) < 100000000
1株当たり純資産が1億円を超えることは実質的にないため、この条件に該当する行は計算元のどちらかが異常だと判断できます。
対応②: 時価総額の計算に使う列にも下限を設ける
同じプレースホルダ値は、BPSだけでなく companies.shares_outstanding にも入り込んでいました。この列は全社共通の時価総額計算に使うため、BPSより影響範囲の広い列です。更新元の条件は value_numeric > 0 しかなく、1 のような明らかに小さい値を弾く仕組みがありませんでした。
FROM edinet_xbrl_facts
WHERE element_name = '発行済株式総数(普通株式)、経営指標等'
AND context_id LIKE 'CurrentYear%%'
- AND value_numeric > 0
+ AND value_numeric >= 100000
(%% はPythonの文字列としてSQLを埋め込んでいるためのエスケープで、SQLとしては CurrentYear% です)
閾値は、他社に現存する最小の発行済株式数が134,380株だったことを確認したうえで10万株に決めました。この根拠にした134,380株自体が、この後の全社棚卸しで異常値と判明した1社の値だったのですが、この時点では気づいていません。 またガードは今後の書き込みを防ぐだけで、すでに 1 が入っている行は直りません。既存の汚染値は直接NULLへ補正しました。
同じ症状が他にないか、全社・全年度で洗い出す
1社を直して終わりにせず、EDINET由来の発行済株式数を全社・全年度で洗い出し、各社の他年度の中央値と比べて10倍以上ずれている行を抽出しました。該当は41行です。
ここで抽出結果をそのまま異常として扱うと、正常なデータまで潰します。株式分割・併合を行った企業は、他年度と株数が大きくずれるのが正しい姿だからです。そこで、同じ書類内にある別のタグ(提出日現在の発行済株式数)を第2の根拠として突き合わせ、株数の変化が別タグでも同じ比率で裏付けられるかを確認しました。
| 区分 | 件数 | 判断 |
|---|---|---|
| 別タグでも同じ比率が裏付けられる | 37行 | 正当な株式分割・併合。修正不要 |
| 別タグと矛盾する | 4行 | 真のデータ異常。修正対象 |
残った4行は3社に集約されました。
| パターン | 内容 | 影響範囲 |
|---|---|---|
| 単年度の桁ずれ | 1年度分だけ他年度の1000分の1(72,088株 / 他年度は72,088,000株) | その年度のBPSのみ |
| 単年度の極小値 | 1年度分だけ200株(他年度は約3,500万株) | その年度のBPSのみ |
| 3年連続の桁ずれ | 直近3年度が約1.34億株のところ134,239〜134,380株 | 最新年度を含むため、時価総額に使う companies.shares_outstanding も本番で汚染 |
対応③: 年度単位の除外と、すでに書き込まれた値の補正
3社とも「その企業の特定年度だけが異常」であり、下限ガードでは弾けません(10万株の下限は3社とも通過します)。既存の除外パターンに倣い、(company_id, fiscal_year) の組で除外する条件を、shares_outstanding 側とBPS側の両方に追加しました。
AND value_numeric >= 100000
+AND NOT (
+ (company_id = 1394 AND fiscal_year = 2023) OR
+ (company_id = 2979 AND fiscal_year = 2021) OR
+ (company_id = 398 AND fiscal_year IN (2023, 2024, 2025))
+)
すでに誤った値で保存されていたBPS 5行は、同じEDINET提出書類の中にある裏付けタグの値で計算し直して補正しました。時価総額側で汚染されていた1社については、ガードを入れた状態で本番のパイプラインを再実行し、直近の正常な年度の株数へ自動的に収束することを実地で確認しています。
Before/Afterをどう証明したか
本番DBを直接書き換える作業なので、「意図した行だけが変わり、それ以外は一切変わっていない」ことを示す必要があります。
- 反映前に
pg_dumpでバックアップを取得し、企業4,595社・通期財務21,711行をidキーで反映前後の全件突合 - 変化したのは
companies1行・financials5行のみで、いずれも想定した対象と一致 - バックアップから復元したスナップショットとも突き合わせ、
shares_outstandingとbps以外の全カラムに差異が0件であることを確認 - 意図的に改ざんしたデータを注入し、比較スクリプトがその差分を検知できることを確認
最後の1つは、比較結果が「差異0件」と出たときに、それが本当に無変化なのか、比較ロジックが壊れていて何も見ていないだけなのかを区別するためのものです。
結果
| 項目 | 修正前 | 修正後 |
|---|---|---|
| BPS更新タスク | 1行の桁あふれで全社分が失敗(3夜連続) | 異常行のみ除外して正常終了 |
| BPSの桁あふれ | ガード無し | 1株当たり純資産1億円以上を計算対象外 |
| 時価総額に使う発行済株式数 |
> 0 のみ(1株でも通過) |
10万株未満を対象外+年度単位の除外 |
| 異常年度の検出範囲 | 発覚した1社のみ | 全社・全年度を棚卸しし3社を追加是正 |
残した課題
検証の過程で、4社について同じ年度に対してEDINETの提出書類が2つ存在し、どちらを採用するかを決める条件が抽出クエリに無いことが分かりました。パイプラインを実行するたびに採用される値が入れ替わります。両者の差は0.003%程度で下流の判定が変わる規模ではないため、今回は修正していません。並び順に決定的な第3のキーを足せば解決できる見込みです。
まとめ
きっかけは1社の発行済株式数が 1 だったことですが、実際に困ったのは、その1行のせいで他社9,882行の更新まで止まっていた点でした。全社を1本のUPDATE文で処理する構成では、1行の異常がそのまま全体の停止になります。
もう1つの収穫は、異常値の全件検索は単独では成立しないと分かったことです。「他年度と比べて10倍ずれている」だけを条件にすると、41行のうち37行を占める正当な株式分割まで異常として扱ってしまいます。同じ書類の中にある別タグを第2の根拠として突き合わせて、初めて4行の真の異常を切り分けられました。異常値を弾く閾値の根拠にした「他社の最小値」自体が汚染された値だった件も含めて、手元のデータだけで異常を判定することの難しさが出た回でした。気になる点・ご質問があればコメントでお知らせください。