このプロジェクトについて
EDINET(有価証券報告書等)の開示データをもとに、日本株の財務指標・投資判断スコアを日次で算出・公開しているサービスです。各社の決算データから売上・営業利益・純利益などの前年比成長率を算出する処理があり、今回はその計算ロジックに潜んでいた問題の話です。
公開サイトはこちら →
https://stocks.happy-life-design.net
前回の記事はこちら →
https://qiita.com/rightcross-ae86/items/e97c5f9274efb93c01a0
発端: 返却件数の重複という表面的な症状
以前から、財務指標を計算する内部関数(fetch_targets())の返却件数に重複があると分かっていました。本来はユニークなはずの行が、特定の企業で複数回返ってくる事象です。原因は未調査のまま残課題として登録し、そのまま時間が経っていました。
あらためて調査したところ、単なる重複だけでなく、重複していない行についても前年比較の対象がずれているケースが見つかりました。
原因: 「年」というラベルでは決算期を区別できない
前年データと結合するprev(前年比較用)のSQLは、決算期の実日付ではなくfiscal_yearという年ラベルで結合していました。
-- 修正前
prev AS (
SELECT company_id, fiscal_year,
revenue AS prev_revenue,
operating_income AS prev_operating_income,
net_income AS prev_net_income
FROM target
),
...
LEFT JOIN prev pv ON pv.company_id = t.company_id AND pv.fiscal_year = t.fiscal_year - 1
fiscal_yearは決算期を年単位に丸めたラベルです。通常は1つの年ラベルに1つの決算期しか対応しませんが、決算期を変更した企業では、同じ年ラベルの中に旧決算期末・新決算期末の2つの決算が存在することがあります。
ある企業では、2023年3月期(period_end=2023-03-31)の決算のあと決算期を12月に変更し、同じ年のうちに2023年12月期(period_end=2023-12-31)の決算も存在しました。どちらもfiscal_year=2023というラベルを持ちます。
このため、2024年12月期(fiscal_year=2024)の行が前年を検索すると、fiscal_year=2023のラベルを持つ2つの行の両方にマッチしてしまい、fetch_targets()全体で同一のfinancials.idが最大136件重複して返っていました。
もう一つの問題: 重複しない行でも、比較対象がずれていた
重複していない行についても確認すると、比較対象自体がずれているケースがありました。上記の企業自身の2023年12月期の行(fiscal_year=2023)が前年を検索する際、fiscal_year = 2023 - 1 = 2022というラベルで探すため、2022-03-31期の決算にマッチしていました。
| 決算期(period_end) | fiscal_yearラベル | 修正前のprev結合先 | 本来必要な比較対象 |
|---|---|---|---|
| 2023-12-31 | 2023 | 2022-03-31期(21ヶ月前) | 2023-03-31期(9ヶ月前) |
fiscal_yearという年単位のラベルだけを見ていたため、「前年」のつもりが実際には21ヶ月前のデータとの比較になっていました。この行は重複していなかったため、件数の重複を追っているだけでは見つかりませんでした。
修正: 決算期の実日付を基準にする
prevCTEを、決算期の実日付(period_end)を基準にしたLAG()ウィンドウ関数に置き換えました。financialsテーブルは(company_id, period_end, period_type, is_consolidated)の組み合わせで一意という制約があるため、対象範囲内でperiod_endが重複することはありません。
-- 修正後
prev AS (
SELECT id,
LAG(revenue) OVER (PARTITION BY company_id ORDER BY period_end) AS prev_revenue,
LAG(operating_income) OVER (PARTITION BY company_id ORDER BY period_end) AS prev_operating_income,
LAG(net_income) OVER (PARTITION BY company_id ORDER BY period_end) AS prev_net_income
FROM target
),
...
LEFT JOIN prev pv ON pv.id = t.id
LAG()は「直前の行」を暦日の並び順で一意に返すため、年ラベルが同じでも決算期が異なれば正しく区別されます。
検証: 影響範囲をどう証明したか
バックアップを取ったうえで全社に反映しました。証明の手順は次の2段階です。
-
fetch_targets()の返却件数を確認し、修正前後で件数がどう変わるかを見る - 修正前のロジックに一時的に戻して(
git stash)同一クエリを実行し、A/B比較で影響範囲を1件単位で特定する
| 区分 | 件数 |
|---|---|
| 修正前の返却件数(重複込み) | 21,847件 |
| 修正後の返却件数(実テーブル件数と一致) | 21,711件 |
| 重複が解消した行 | 136件 |
| 重複はなかったが比較対象が変わった行 | 101件 |
| 値が変わった行の合計 | 237件 |
| 完全に不変だった行 | 21,474件 |
値が変わった237件はいずれも決算期変更企業の行でした。上記の企業の該当行では、修正後の前年比成長率が手計算の値と一致することも確認しています。
まとめ
今回の修正で値が変わったのは237件、全体の1%強です。割合としては小さいですが、対象となった企業では、前年比成長率という投資判断に使う数値そのものが、決算期を変更するたびに無関係な期間と比較され続けていたことになります。
重複という表面的な症状を追いかけていったら、指標の正しさに関わる問題にたどり着きました。fiscal_yearのような年単位のラベルでデータを結合するときは、暦年と決算期が必ずしも1対1ではないことを前提にする必要があると学びました。
気になる点・ご質問があればコメントでお知らせください。