Engineering data-infra 35 min read

決算短信を毎朝自動取得する medallion の設計 — XBRL / PDF スクレイピングと YAML 設定駆動

JPX・TDnet・各社 IR から決算短信を取得する Python ベースの medallion 設計。XBRL / PDF 差異吸収・銘柄別 YAML 設定・launchd 日次実行・IFRS / JGAAP 対応の実装全体を開示。

公開 2026-05-23 森本 拓見

本記事に登場する HUMAN_INPUT マーカーとは、AI 執筆 skill が記事本文に残す「ここは人間が後で確定値を埋めるべき」を示すプレースホルダー(形式例: <!-- HUMAN_INPUT: 数値を記入 -->)。

本記事はデータエンジニア / シニアエンジニア / アーキテクト向けに、medallion(決算短信を毎朝自動取得して共有スプレッドシートに蓄積する Python ベースのデータ収集ツール)の設計全体を開示する。ROI・コスト比較・投資判断の観点は sister pillar 「決算データ収集自動化の経営判断と ROI」 に委ねる。ここでは実装の構造そのものを扱う。

Key takeaway 3 点:

  1. 3 収集元(TDnet / 各社 IR / EDINET)を優先度付き多段フォールバックで組み、XBRL と PDF の差異は「PDF 主・XBRL 補完」のマージパターンで吸収する
  2. 銘柄ごとの取得戦略(url_template / page_scrape / edinet_only)は YAML に閉じ込める。コードに銘柄依存のロジックを持ち込まない
  3. launchd の日次実行は「TDnet 開示リスト × master.meta 登録銘柄 の intersect」で絞り込む。全銘柄スキャンをやめたことで処理時間とエラーノイズが大きく減った

medallion の全体アーキテクチャ

収集対象の定義 — 金融データ自動取得パイプラインの 3 収集元

medallion が収集する「決算短信」は、日本の上場企業が四半期・半期・通期ごとに JPX の規則に基づいて提出する財務サマリー文書だ。1 社につき年 2〜4 回開示され、売上高・営業利益・当期純利益・BS 要約・CF 要約・業績予想が盛り込まれる。

収集元は 3 つ、優先度付きで使い分ける:

ソース対象強み弱み
TDnet(東証適時開示システム)最新決算短信(速報 PDF)当日〜3 日前の速報を確実に取れる過去データは保持期間が限られる
各社 IR サイト過去 5〜10 年分の決算短信 PDF長期履歴を取れる各社 URL 規則がバラバラ
EDINET(金融庁電子開示)有価証券報告書・半期報告書 XBRL / PDF構造化データとして取りやすい速報より遅い(月単位の遅延あり)

設計の原則は「速報は TDnet、歴史データは IR、構造化データは EDINET」という責務分担だ。3 源を組み合わせることで、1 つのソースが変わっても全体が止まらない。

データフロー — 取得 → パース → 正規化 → Sheets 書き込み

[TDnet / IR / EDINET]
       |
  fetch フェーズ
  (tdnet.py / ir_jp.sh / edinet_search.py)
       |
  stage フェーズ
  (kessan_pdf.py / kessan_xbrl.py / edinet_xbrl.py)
  ↓ JSON (フィールド単位の provenance 付き)
  kessan_merge.py (PDF 主 + XBRL 補完)
       |
  write フェーズ
  (schema.py → sheets_io.py → GWS Sheets)

フェーズを fetch / stage / write の 3 段に分けているのは再実行を安全にするためだ。bulk_runner.sh--phase fetch|stage|write|all フラグを持ち、途中で失敗しても後続フェーズだけ再実行できる。ネットワーク障害で fetch が途中停止した場合は --phase stage 以降をやり直せば済む。

ディレクトリ構造と主要モジュール

scripts/earnings/
├── fetcher/                  # GWS 依存ゼロ(将来 MCP サーバ化を想定)
│   ├── schema.py             # 63 列スキーマ・期間生成・JSON→行変換
│   ├── kessan_merge.py       # PDF 主 + XBRL 補完マージ
│   ├── extractors/
│   │   ├── kessan_pdf.py     # pdfplumber ベース PDF 抽出
│   │   ├── kessan_xbrl.py    # arelle / xml.etree ベース XBRL 抽出
│   │   ├── edinet_xbrl.py    # EDINET 有報 XBRL パーサ
│   │   └── ixbrl_html.py     # inline XBRL (iXBRL) HTM パーサ
│   └── sources/
│       ├── tdnet.py          # TDnet 一覧取得 + PDF URL 解決
│       ├── ir_yaml.py        # 銘柄別 YAML 設定読み込み + PDF URL 解決
│       └── edinet_search.py  # EDINET API クライアント

└── montage_integration/      # GWS 依存あり(orchestration 層)
    ├── sheets_io.py          # Sheets 読み書きラッパー
    ├── history_runner.sh     # 単一銘柄の過去分取得オーケストレータ
    ├── bulk_runner.sh        # 複数銘柄の並列実行
    └── daily_tdnet_runner.sh # 日次実行ラッパー(launchd から呼ばれる)

fetcher/ を GWS 依存ゼロに保っているのは将来の MCP サーバ化を視野に入れているためだ。スプレッドシートへの書き込みロジックを montage_integration/ に閉じ込めることで、fetcher 層を取り出して別環境で再利用できる構造になっている。


銘柄別 YAML 設定駆動の設計

YAML 設定ファイルの構造 — JPX TDnet 自動取得実装の設定基盤

config/companies/{ticker}.yaml が 1 銘柄 1 ファイルの設定 SSOT だ。最小構成は次のようになる:

# config/companies/7203.yaml(トヨタ自動車の例)
ticker: "7203"
company: "トヨタ自動車"
fiscal_month: 3
sector: "自動車"
accounting_std: "IFRS"
ir_url: "https://global.toyota/pages/global_toyota/ir/financial-results/"
short_term_template: "https://global.toyota/pages/global_toyota/ir/financial-results/{FY}_{QNUM}q_summary_jp.pdf"
yuho_template: null
scraper_strategy: "url_template"
notes: "IFRS 連結。営業利益は有報に記載なし(税引前利益のみ)。"

{FY} は会計年度末の西暦(例: 2024)、{QNUM} は四半期番号(1〜4、4 = 通期)を表す。URL テンプレートに埋め込むことで、期ごとの PDF URL をコードなしで生成できる。

各社 IR の URL 規則がバラバラな場合(ソニーグループのように年度で URL パターンが変動する場合)は scraper_strategy: "page_scrape" を使い、IR ページを走査してリンクテキストで PDF を特定する:

# config/companies/6758.yaml(ソニーグループの例)
ticker: "6758"
company: "ソニーグループ"
accounting_std: "IFRS"
scraper_strategy: "page_scrape"
ir_pages:
  - url: "https://www.sony.com/ja/SonyInfo/IR/library/presen/"
    pdf_link_pattern: "決算短信"
  - url: "https://www.sony.com/ja/SonyInfo/IR/library/presen/archive/"
    pdf_link_pattern: "決算短信"
ir_page_selector: "a[href*='.pdf']"

scraper_strategy の選択肢は 3 つ:

戦略用途
url_templateIR PDF の URL が {FY}_{QNUM}q_summary.pdf のようにテンプレート化できる場合
page_scrapeURL パターンが年度で変動し、IR ページを走査してリンクテキストで特定する必要がある場合
edinet_onlyIR サイトへの直接取得が困難で、EDINET 経由のみの場合

設定駆動にした理由 — ハードコード回避の設計判断

初期実装で最初に直面した問題は「各社 IR サイトの URL 規則がバラバラすぎてコードに書けない」という事実だ。

トヨタは {FY}_{QNUM}q_summary_jp.pdf というテンプレートだが、別の企業は /ir/kessan/{YY}_{MM}/summary.pdf、また別の企業は年度が変わると URL 構造ごと変わる。これをコードに if 文で書き続けると、銘柄追加のたびにコード修正が必要になり、テストも複雑化する。

YAML に銘柄依存のロジックを閉じ込めると、コードは「YAML を読んで URL を解決する」という単一責務になる。ir_yaml.py は次のように動く:

# ir_yaml.py より抜粋(概念を示す擬似コード)
def resolve_url(config: dict, fy: int, quarter: int) -> str | None:
    strategy = config.get("scraper_strategy", "url_template")
    
    if strategy == "url_template":
        tmpl = config.get("short_term_template")
        return tmpl.format(FY=fy, QNUM=quarter) if tmpl else None
    
    elif strategy == "page_scrape":
        return scrape_ir_page(config)  # IR ページを走査
    
    elif strategy == "edinet_only":
        return None  # EDINET フォールバックに委ねる

銘柄追加は YAML ファイルを 1 本追加するだけで済む。コードのリリースは不要だ。

銘柄追加・削除の運用手順

# 1. YAML を作成
cp config/companies/7203.yaml config/companies/9999.yaml
# 内容を編集(ticker / company / ir_url / scraper_strategy を修正)

# 2. YAML 設定の検証
python3 scripts/earnings/fetcher/sources/ir_yaml.py \
  --ticker 9999 --project-root . dump

# 3. 単一銘柄で動作確認(直近 2 年分)
bash scripts/earnings/montage_integration/history_runner.sh \
  9999 "テスト企業" 3 --years 2

# 4. マスタースプレッドシートに meta 情報が書き込まれていることを確認
# (GWS コンソールで meta タブを確認)

2026 年 5 月時点で config/companies/ に 54 銘柄分の YAML が登録されている。アクティブ登録数と削除済み(YAML 残存だが未使用)の明確な区別は YAML ファイル上は持たせていない。


XBRL / PDF 差異吸収の設計

XBRL 対応企業のパース戦略 — XBRL スクレイピング設計の核心

XBRL(eXtensible Business Reporting Language)は EDINET と TDnet が採用する構造化財務報告フォーマットだ。タグに概念名(NetSales / OperatingIncome など)が付いているため、PDF テキスト抽出と比べてパース精度が高い。

kessan_xbrl.py の設計方針は次の 2 点だ:

1. arelle を優先、xml.etree をフォールバックにする

try:
    from arelle import Cntlr, ModelManager, ModelXbrl
    ARELLE_AVAILABLE = True
except ImportError:
    ARELLE_AVAILABLE = False

arelle は XBRL のネイティブパーサで、名前空間・コンテキスト・ユニットを正確に扱える。ただし環境によってインストールが難しい場合があるため、arelle が利用できない環境では xml.etree.ElementTree による手動パースにフォールバックする。

2. コンセプトマップで「ローカル名 → フィールド名」を解決する

各社の XBRL タグ名は会計基準(JGAAP / IFRS / US-GAAP)によって異なる。kessan_xbrl.py は優先順位付きのコンセプトマップで吸収する:

# JGAAP / IFRS / US-GAAP 共通マップ(優先順位順)
CONCEPT_PL: dict[str, list[str]] = {
    "revenue": [
        "NetSales",            # JGAAP 製造業
        "Revenues",            # JGAAP 金融
        "OperatingRevenues",   # JGAAP 電力・ガス
        "Revenue",             # IFRS
        "SalesAndOperatingRevenues",
    ],
    "operatingIncome": [
        "OperatingIncome",                          # JGAAP
        "ProfitLossFromOperatingActivities",        # IFRS
        "OperatingIncomeLoss",                      # US-GAAP
    ],
    ...
}

リストの先頭から順に「この名前のタグが XBRL に存在するか」を探し、最初に見つかった値を採用する。JGAAP と IFRS の概念差(JGAAP には「経常利益」があるが IFRS にはない)はコンセプトマップの定義で対処する。

iXBRL(inline XBRL)への対応

JPX の「決算短信 XBRL」は通常の XBRL ZIP とは異なり、HTML の中に XBRL タグが inline で埋め込まれた iXBRL 形式だ(.htm 拡張子)。ixbrl_html.pylxml で HTML をパースし、ix:nonfraction / ix:nonnumeric タグを抽出する専用パーサだ。

# ixbrl_html.py の処理の核(概念を示す擬似コード)
def extract_ixbrl(html_content: str) -> dict:
    tree = etree.fromstring(html_content.encode(), parser=html_parser)
    for elem in tree.iter():
        # ix:nonfraction タグ(数値)を対象に
        if elem.tag.endswith("}nonfraction"):
            name = elem.get("name", "")  # 例: "jppfs_cor:NetSales"
            context_ref = elem.get("contextRef", "")
            value = clean_number(elem.text_content())
            yield (name, context_ref, value)

PDF 対応企業のパース戦略

kessan_pdf.pypdfplumber を使って PDF を解析する。決算短信 PDF には標準的なレイアウト規則がある:

  • Page 1: サマリー(PL 要約・BS 要約・配当・業績予想)
  • Page 5-6: BS(資産・負債)
  • Page 7-8: PL(損益計算書)
  • Page 10: CF(キャッシュフロー計算書)

しかし実際には以下の問題が発生する:

  1. 全角数字: 123,456 のような全角表記を半角変換する必要がある
  2. △(マイナス)記号: 損失を表す三角記号を負の数として扱う
  3. 連続文字描画: 一部の決算短信 PDF では「IIIFRS」のような文字を複数回重ねて描画するため、普通の文字列検索では「IFRS」が見つからない
def _collapse_repeated_chars(s: str) -> str:
    """連続する同一文字を 1 文字に圧縮。
    
    双日(2768)のような PDF は "IIIIIFFFFFRRRRRSSSSS" で描画する。
    圧縮すると "IFRS" になり、substring 検索が通る。
    """
    if not s:
        return s
    return re.sub(r"(.)\1{2,}", r"\1", s)

pdfplumber を選んだのは、テキスト抽出と表構造抽出を 1 ライブラリの API(page.extract_text() / page.extract_tables())で扱えるためだ。決算短信 PDF はサマリー本文(散文)と財務諸表(表)の両方を含むため、両方を 1 ライブラリで完結できる点を優先した。pymupdf / pdfminer との定量ベンチマークは取っていない。pdfplumber 固有の弱点(IFRS PDF で表抽出が複数行を 1 セルに結合してしまうケース等)は個別の workaround を内側で書く方針で対処している(kessan_pdf.py 内に複数の構造再構成ロジックを実装)。

XBRL / PDF を統一スキーマに正規化する設計

kessan_merge.py は「PDF を主、XBRL を補完」という方針でマージする:

# kessan_merge.py の設計方針(概念コード)
def merge(pdf_data: dict, xbrl_data: dict) -> dict:
    result = copy.deepcopy(pdf_data)
    
    for field in SCALAR_FIELDS:
        pdf_val = get_scalar(pdf_data, field)
        if pdf_val is None:  # PDF が取れなかったフィールドのみ XBRL で埋める
            xbrl_val = get_scalar(xbrl_data, field)
            if xbrl_val is not None:
                set_scalar(result, field, xbrl_val)
                result.setdefault("_xbrl_filled_fields", []).append(field)
    
    # provenance を記録
    filled = result.get("_xbrl_filled_fields", [])
    result["_source"] = "pdf+xbrl" if filled else "pdf"
    return result

_source フィールドに "pdf" / "pdf+xbrl" を記録し、後で「この値は PDF から来たか XBRL から来たか」を追跡できるようにしている。これは データ品質の問題が発生したときのデバッグに使う。

最終的に schema.pymap_to_*_row() 関数が、マージ済み JSON を Google Sheets の 63 列フォーマットに変換して書き込む。


IFRS / JGAAP 対応の設計

IFRS と JGAAP のデータ構造の違い

日本の上場企業は JGAAP(日本会計基準)または IFRS(国際財務報告基準)を採用している。JPX の集計(2025-08-08 公表)では IFRS 適用済 287 社・適用決定 8 社・適用予定 5 社の 計 300 社 が IFRS を採用しており、時価総額シェアは 2025-06-30 時点で 49.8% に達する。社数ベースでは依然として JGAAP が多数派だが、時価総額の半分近くは IFRS という構造のため、両基準を同じ pipeline で扱えないと「大型銘柄が拾えない」という致命的な穴ができる。

主な差異:

項目JGAAPIFRS
経常利益あり(金融費用を除いた営業外損益を含む)なし(代わりに「税引前利益」)
売上原価の区分売上総利益 → 販管費 → 営業利益 の段階表示費用性質法・機能法のいずれかで開示(任意)
少数株主持分負債・資本の間に表示資本の一部として表示
四半期報告書2024/3 期まで義務。2024/10 以降は半期報告書に移行同上

これらの差異を吸収するために、schema.py は「JGAAP にあって IFRS にない概念」のフィールドには None を許容する設計になっている。ordinaryIncome(経常利益)は JGAAP 企業のみ値を持ち、IFRS 企業は常に None だ。

統一フォーマットへの正規化戦略

統一スキーマのキーは「JGAAP の概念名を基準に、IFRS のマッピングをコンセプトマップで解決」する方針だ。

IFRS の ProfitLossBeforeIncomeTaxes は JGAAP の「税引前当期純利益」に相当すると解釈し、pretaxIncome フィールドに格納する。JGAAP の OrdinaryIncome に相当する IFRS のタグは存在しないため、ordinaryIncome は IFRS 企業では常に空になる。

この設計の意図は「概念がない場合は空にする」という正直な表現だ。IFRS の ProfitLossordinaryIncome として代入してデータを埋めると、比較分析の際に誤った結論を導く。

会計基準を YAML 設定で切り替える設計

config/companies/{ticker}.yamlaccounting_std フィールドが "IFRS""JGAAP" かで、パーサの動作を切り替える:

# kessan_xbrl.py のコンテキスト選別(概念コード)
def select_context(contexts: list[Context], accounting_std: str) -> Context:
    if accounting_std == "IFRS":
        # IFRS は "CurrentYearDuration" + 連結 (_ConsolidatedMember なし)
        preferred = [c for c in contexts if "CurrentYearDuration" in c.id]
    else:
        # JGAAP は "CurrentYearDuration" または "FilingDateInstant"
        preferred = [c for c in contexts if "CurrentYearDuration" in c.id]
    return preferred[0] if preferred else contexts[0]

IFRS 採用企業と JGAAP 採用企業が混在したまま並列処理できるのは、この切り替えロジックが accounting_std フィールドに委譲されているためだ。


launchd 日次実行の設計

launchd plist の設計 — 毎朝 5 時の自動実行

macOS の launchd を使って毎朝 05:00 に daily_tdnet_runner.sh を実行する。plist の核心部分:

<!-- config/launchd/com.medallion.tdnet-daily.plist -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.medallion.tdnet-daily</string>

    <key>ProgramArguments</key>
    <array>
        <string>/opt/homebrew/bin/bash</string>
        <string>/path/to/medallion/scripts/earnings/montage_integration/daily_tdnet_runner.sh</string>
    </array>

    <!-- 毎朝 05:00 JST に実行 -->
    <key>StartCalendarInterval</key>
    <dict>
        <key>Hour</key>
        <integer>5</integer>
        <key>Minute</key>
        <integer>0</integer>
    </dict>

    <key>StandardOutPath</key>
    <string>/Users/t.morimoto/Library/Logs/medallion-tdnet-daily.out.log</string>
    <key>StandardErrorPath</key>
    <string>/Users/t.morimoto/Library/Logs/medallion-tdnet-daily.err.log</string>
</dict>
</plist>

05:00 を選んだのは、9:00 の市場オープンまでに前営業日分の開示を Sheets に反映させたいためだ。pre-market バッチとしての設計で、TDnet 自体の開示受付時刻に合わせているわけではない(TDnet は平日 9:00 開始)。05:00 起動なら、PDF / XBRL 取得・解析・Sheets 書き込みを含めても 4 時間の余裕があり、寄付き前に当日のミーティング・分析資料に間に合う。

daily_tdnet_runner.sh の 4 ステップフロー

日次実行の核心は「全銘柄をスキャンせず、TDnet 開示 × master.meta 登録銘柄の intersect のみを処理する」設計だ:

1. TDnet 開示リスト取得
   → output/tdnet/YYYY-MM-DD/disclosures.json
   
2. master.meta からの登録済み ticker 読み込み
   (gws sheets +read --range "meta!A2:E")
   
3. TDnet ∩ master.meta の intersect 計算
   → output/tdnet/YYYY-MM-DD/medallion_tickers.txt
   
4. bulk_runner.sh 呼び出し(parallel=8)
   → history_runner.sh を銘柄ごとに並列実行
# daily_tdnet_runner.sh の intersect ロジック(抜粋)
python3 - <<PYEOF
import json

# TDnet 当日開示リスト
with open('disclosures.json') as f:
    disclosures = json.load(f)
tdnet_tickers = {item['ticker'] for item in disclosures}

# master.meta 登録銘柄
meta_map = {r['ticker']: r for r in meta_records}

# intersect
intersect = sorted(tdnet_tickers & meta_map.keys())
PYEOF

以前の設計(earningsSchedule シートに月次で予定を手動入力してバルク実行)と比べると、この intersect 方式の利点は「ヒューマンメンテナンスが不要になる」点だ。誰かが earningsSchedule シートを更新し忘れても、TDnet の実際の開示リストから自動検出するため、取り漏れが発生しない。

取得失敗時のリトライ設計

daily_tdnet_runner.sh は次の失敗分類に従って応答する:

失敗の種類対応
TDnet サイト自体が応答しない(HTTP エラー / タイムアウト)正常終了(exit 0)、翌朝 launchd が再試行
master.meta が読めない(GWS 認証切れ等)エラー終了(exit 1)、launchd が記録
bulk_runner が失敗(個別銘柄の fetch エラー)警告ログに記録。次回以降の実行で再試行

TDnet 障害を exit 0 で飲み込むのは意図的な設計だ。launchd の StartCalendarInterval は実行が失敗しても「次回スケジュール」を維持するため、翌朝自動的に再試行される。

アラートの実装

現時点では Slack / メール通知は未実装。daily_tdnet_runner.sh にアラート送信のコードは存在しない。README に将来実装予定として記載されており、現在は tail -f によるログ監視が唯一の確認手段だ。

ログファイルは ~/Library/Logs/medallion-tdnet-daily.log.out.log / .err.log の 3 ファイルに出力される。標準的な tail -fgrep ERROR による確認が可能だ。


Google Sheets への書き込み設計

Sheets API 連携の設計 — 認証・レート制限・バッチ書き込み

sheets_io.py は Google Workspace CLI(gws コマンド)を subprocess として呼び出すラッパーだ。Sheets API を直接使わず CLI 経由にしているのは、GWS 認証プロファイルの管理を CLI 側に任せるためだ。

バッチ書き込みの設計:

# sheets_io.py の書き込み(概念コード)
def write_rows(sheet_id: str, tab: str, rows: list[list]) -> None:
    # 数値文字列を数値に強制変換(GWS CLI は文字列と数値を区別しない)
    coerced = coerce_numeric(rows)
    
    gws(["sheets", "batchUpdate",
         "--spreadsheet", sheet_id,
         "--range", f"{tab}!A1",
         "--values", json.dumps(coerced)])

GWS CLI の呼び出し側(sheets_io.py)に明示的なレート制限設計はない。ただし EDINET API クライアント(edinet_search.py)は _rate_limit() メソッドを持ち、time.sleep によるスロットリングを実装している。GWS Sheets API の呼び出し頻度は銘柄数 × 8 タブの書き込みが集中するが、直列処理(銘柄単位では --parallel 8 だが Sheets 書き込みはシリアル)のため過剰リクエストになりにくい。

既存データとの差分更新設計

medallion の書き込みは「上書き」ではなく「差分 upsert」だ。Period 列(例: 2024.03-Q1)を主キーとして、既存行は更新、新規行は末尾に追加する。

スキーマの核は schema.pyHEADERS_* 定数だ。8 タブ(pl / bs / cf / per_share / forecast / segments / plans / meta)それぞれに専用ヘッダ定数を持ち、map_to_*_row() 関数がマージ済み JSON をフラットな行配列に変換する。

# schema.py より(実際の定数)
HEADERS_PL = [
    "Ticker", "Period",
    "Revenue", "Op_Income", "Ordinary_Income", "Pretax_Income",
    "Net_Income", "Comprehensive_Income",
    "Cost_of_Revenue", "Gross_Profit", "SGA",
    "Non_Op_Income", "Non_Op_Expense",
    "Extraordinary_Gain", "Extraordinary_Loss",
    "Income_Tax", "Minority_Interest",
    "Interest_Expense",  # metrics の Interest_Coverage に使用
]

設計の判断記録 — なぜこの構造にしたか

DB を使わず Sheets に書き込む理由

金融データを蓄積するツールとして「なぜ PostgreSQL や BigQuery を使わないのか」は正当な問いだ。

Sheets を選んだ理由は、主な利用者(八雲内の非エンジニアメンバー)がすでに Google Sheets の操作に習熟しており、分析・集計・可視化をその場でできるためだ。DB に入れると「SQL を書ける人間が必要」という前提が発生する。

副次的な理由として、GAS(Google Apps Script)との統合コストがゼロに近い。メトリクス計算・グラフ自動生成・Slack 通知などを Sheets → GAS のパイプラインで実装済みだ。

デメリットは明確にある。Sheets の行数制限(1 シート 1,000 万セル)・並行書き込み時の競合・複雑なクエリの難しさ、この 3 点だ。現時点で medallion が追跡している銘柄は 54 銘柄で、各銘柄が複数年分 × 8 タブの数値を Sheets に書き込む構成だ。1 銘柄 1 期間あたり pl/bs/cf/per_share/forecast/plans の単行系 6 タブで計 81 列、segments タブの複数行(平均 5 セグメント × 10 列 = 50 セル)を足して約 130 セルを占有する。年 5 期間(四半期 4 + 通期 1)× 10 年分の履歴で 1 銘柄あたり約 6,500 セル。MVP として想定している NI225 + TOPIX Core 30 + TOPIX 100 計 231 銘柄 を載せても約 150 万セルで、1,000 万セル制限の 約 15% にとどまる。年次の追加データは 231 銘柄 × 5 期間 × 130 セル ≒ 15 万セル / 年で、現構成のまま 50 年以上分の追加余地がある。Sheets 行数制限は MVP スコープでは事実上ボトルネックにならない。

YAML 設定駆動にした理由

「設定をコードに書く vs 設定ファイルに外出す」の判断は、「変更頻度がコードと異なるか」で決める。

銘柄の IR URL は「年 1 回程度変わる」頻度だが、コードのリリースは本来もっと慎重に行いたい。URL 変更のたびにコードを編集してテストして PR を出して…というフローは過剰だ。YAML に外出せば、URL 変更は YAML 1 行の編集で済む。コードレビューは不要で、CI も回さない。

この判断は「変更頻度の違いが設計を決める」という原則の適用だ。同じ原則が schema.py のヘッダ定数設計にも現れている。

XBRL / PDF を並列処理にしなかった理由

fetch フェーズで XBRL と PDF を同時に取得する並列化は一見効率的に見えるが、採用しなかった。

理由は 2 つ:

  1. 依存関係: XBRL と PDF は別のソースから取得するが、マージ(kessan_merge.py)は両方が揃ってから実行する必要がある。並列化すると「片方だけ取れた状態」の中間ステートが発生し、再試行設計が複雑になる

  2. サーバー負荷: TDnet / EDINET に対して同一 IP から高頻度でリクエストを送ることへの懸念。tdnet.pyTIMEOUT_SEC = 15 / MAX_RETRIES = 1 で保守的な設定にしており、並列化でこれを崩したくない

銘柄間の並列化--parallel 8)は実装している。同一銘柄の XBRL と PDF を直列で取りながら、銘柄単位では 8 並列で動く。実測で 50 銘柄を約 20 分(1 銘柄あたりの wall-clock 24 秒、並列を考慮した 1 銘柄実処理は約 3 分)で完了する。日次の daily_tdnet_runner.sh は TDnet 開示 × master.meta 登録銘柄の intersect のみを処理する設計なので、決算集中日でも対象は数十銘柄に収まり、05:00 起動でも 9:00 の市場オープンまでに十分間に合う。


落とし穴と実装上の注意点

落とし穴 1: JPX 短信 XBRL は「iXBRL」形式

TDnet から取得できる決算短信 XBRL は、通常の XBRL ZIP ではなく iXBRL(inline XBRL)形式の .htm ファイルだ。XBRL のタグが HTML の中に埋め込まれている。

arelle を使うと自動でハンドリングしてくれるが、手動パースの場合は ix:nonfraction / ix:nonnumeric タグを lxml で探す必要がある。汎用 XBRL パーサをそのまま流用しようとするとこの点で詰まる。

落とし穴 2: 決算短信 PDF の「重ね描画フォント」問題

一部の企業は決算短信 PDF で文字を複数回重ね描画するフォーマットを使う。pdfplumber が取り出すテキストは "IIIIIFFFFFRRRRRSSSSS" のようになる。

_collapse_repeated_chars() で連続する同一文字を 1 文字に圧縮してから文字列検索することで対処できる。「テキスト抽出結果がおかしい」と感じた場合は、まずこの問題を疑う。

落とし穴 3: IFRS 企業の営業利益取得

IFRS 採用企業(トヨタ・ソニー等)の決算短信 PDF には「営業利益」の記載がないケースがある。IFRS では「営業利益」の定義が標準化されておらず、企業が独自定義で開示するか、有価証券報告書でのみ開示する場合がある。

EDINET 有報からの XBRL 取得(edinet_xbrl.py)でフォールバックすることで対応しているが、速報 PDF のみでは Op_IncomeNone になる銘柄が存在する。これは設計上の許容範囲だ。

落とし穴 4: 四半期報告書廃止と半期報告書移行

2024 年 3 月期を最後に、日本の上場企業は四半期報告書の提出義務がなくなり、半期報告書(新制度)に移行した。これは edinet_search.py の書類種別コード(docTypeCode)に影響する:

# EDINET 書類種別コード(edinet.json より)
# 旧制度
"四半期報告書 Q1/Q3": "140",  # 2024/3 期まで
# 新制度
"半期報告書 H1": "160",        # 2024/10 以降

2024 年以降の H1(上半期)データを取得しようとすると 140 ではなく 160 を使う必要がある。YAML の accounting_std と合わせて、取得期間に応じて書類種別を切り替えるロジックが必要だ。

落とし穴 5: launchd のスリープ復帰挙動

launchd の StartCalendarInterval は、Mac がスリープ中だった場合にスリープ復帰後にまとめて実行される挙動を持つ。週末に Mac を閉じていると、月曜朝の 05:00 実行が月曜日のスリープ復帰時にまとめて 3 回実行される可能性がある。

daily_tdnet_runner.sh は実行ごとに output/tdnet/YYYY-MM-DD/disclosures.json を出力するため、同日 2 回実行されても冪等だ。ただし重複書き込みが発生するため、sheets_io.py の upsert ロジックが正しく動く前提がある。


まとめ — medallion 実装チェックリスト

medallion 設計の全体を整理する。データエンジニアが同様の金融データ収集パイプラインを構築する際の参考として持ち帰れる原則を最後に列挙する。

設計判断サマリー

判断採用したアプローチ理由
データ取得元TDnet → IR → EDINET の多段フォールバック1 ソース依存を避けてカバレッジを確保
銘柄設定YAML 1 銘柄 1 ファイルコードを変えずに銘柄追加・URL 変更を吸収
XBRL / PDF 差異PDF 主・XBRL 補完のマージPDF の方が速報性が高いため
会計基準差異YAML accounting_std + コンセプトマップコードに if 文を持ち込まない
日次実行TDnet ∩ master.meta の intersect全銘柄スキャンの廃止でエラーノイズを削減
データストアGoogle Sheets利用者の習熟コストと GAS 連携を優先

実装チェックリスト

  • config/companies/{ticker}.yaml を 1 ファイル作成し、ir_yaml.py --ticker {n} dump で動作確認
  • scraper_strategyurl_template / page_scrape / edinet_only から選択
  • kessan_xbrl.pyCONCEPT_PL / CONCEPT_BS に対象会計基準のタグ名を追加
  • schema.pyHEADERS_* とスプレッドシートのヘッダが一致していることを確認
  • history_runner.sh --years 2 で単一銘柄の動作確認
  • launchd plist の StartCalendarInterval を設定して launchctl load
  • daily_tdnet_runner.sh --dry-run で intersect 結果を事前確認
  • ログファイル(~/Library/Logs/medallion-tdnet-daily.log)が出力されていることを確認

同じ medallion データを使った経営分析・投資判断の自動化については、sister pillar 「決算データ収集自動化の経営判断と ROI」 で扱っている。tech 設計に興味があるエンジニア向けにはこの記事、データ活用の経営判断に興味がある読者には sister pillar という使い分けを想定している。

SHARE X でシェア B! はてブ