EDGAR XBRLを読み込むコード例【Python】

スポンサーリンク

EDGAR XBRLから米国企業の決算データを抽出するコード例です。

エドガー(EDGAR)の書類は、フォーム・タイプ(Form Type)という文字列で分類されているのですが、「テン・ケイ(10-K)」と「テン・キュー(10-Q)」ですね、これが企業の決算書になります。

このXBRLを読み込むためのPythonコードです。

XBRLを解析するクラス

XBRL解析で財務情報などを取得するクラスです。

名前空間を定義

名前空間はタグを検索するときに使います。

"""xbrl_namespace"""

from re import compile as re_c

# 名前空間
NS_INSTANCE = {
    'dei': re_c('^(?:%s|%s)/[1-9][0-9]{3}-[01][0-9]-[0-3][0-9]$' % (
        'http://xbrl\.sec\.gov/dei',
        'http://xbrl\.us/dei',
        )).match,
    'link': 'http://www.xbrl.org/2003/linkbase',
    'xbrldi': 'http://xbrl.org/2006/xbrldi',
    'xbrli': 'http://www.xbrl.org/2003/instance',
    'xlink': 'http://www.w3.org/1999/xlink',
    'xsi': 'http://www.w3.org/2001/XMLSchema-instance',
}

一部、正規表現を使っていますが、年度によって日付が変わったり、名前そのものが変わったりする場合があるんですね。それらのパターンをまとめるのに使用しています。

XBRLファイルを読み込む

lxml.etree でXBRLファイルを読み込むところです。バイナリデータを受け取った時は、そちらを読み込みます。

XBRL自体は単なるテキストファイルなのですが、XMLとして中身を自在に検索するために、lxml.etree で読み込んでいます。

"""xbrl_util"""

from traceback import format_exc
from lxml.etree import XMLParser as etree_XMLParser
from lxml.etree import fromstring as etree_fromstring
from lxml.etree import XMLSyntaxError


def get_etree_obj(file, file_data=None):
    """XMLファイルを読み込む"""
    parser = etree_XMLParser(recover=False, huge_tree=True)
    try:
        if file_data is None:
            with open(file, 'rb') as f:
                return etree_fromstring(f.read(), parser=parser)
        else:
            return etree_fromstring(file_data, parser=parser)
    except XMLSyntaxError:
        print(format_exc())

    print('再解析 recover=True\n%s' % file)
    parser = etree_XMLParser(recover=True, huge_tree=True)
    if file_data is None:
        with open(file, 'rb') as f:
            root = etree_fromstring(f.read(), parser=parser)
    else:
        root = etree_fromstring(file_data, parser=parser)
    return root

ファイルの読み込み設定は XMLParser() の引数で行います。設定値の入ったインスタンスが返ってきますので、それを fromstring() に渡してあげます。

recover はエラー回復モードの設定で、huge_tree は巨大なXMLの読み込みを許可するかの設定です。huge_tree の設定に関しては以下の記事をご覧ください。

Memory allocation failed を回避する【lxml.etree】

クラスの初期化

XBRL解析クラスの初期化部分です。ここでファイルの読み込みからデータ抽出まで行っています。

"""xbrl_edgar"""

from os.path import join as os_join
from os.path import basename as os_basename
from os.path import dirname as os_dirname
from re import compile as re_compile
from collections import OrderedDict
from xbrl_namespace import NS_INSTANCE as ns_def
from xbrl_util import get_etree_obj

# 改行コードを置換するための正規表現
RE_NEWLINE_SUB = re_compile(r'\r\n|\r|\n').sub


class Parser:
    """xbrlファイル解析クラス"""
    def __init__(
            self, file, file_data,
            skip_textblock, skip_text_len, strip_space, newline):
        # テキストブロックをスキップ。ただしDEIはスキップしない。
        self.skip_textblock = skip_textblock

        # 指定した文字数を超えるテキストを持っていたらスキップ
        self.skip_text_len = skip_text_len

        # テキストの先頭と末尾の空白文字類を削除
        self.strip_space = strip_space

        # 指定した改行コードで置換
        self.newline = newline

        # XBRLファイル読み込み
        self.root = get_etree_obj(file, file_data)

        # 名前空間接頭辞の辞書作成
        self.ns_prefixes = {v: k for (k, v) in self.root.nsmap.items()}

        # DEI(Document and Entity Information)を
        # 定義している名前空間を取得
        for ns in self.root.nsmap.values():
            if ns_def['dei'](ns):
                self.ns_dei = ns
                break
        else:
            self.ns_dei = None
            print('warning DEIの名前空間が存在しない')

        # タグ名/属性名定義
        self.link_schema_ref = '{%s}schemaRef' % ns_def['link']
        self.xlink_href = '{%s}href' % ns_def['xlink']
        self.xbrli_context = '{%s}context' % ns_def['xbrli']
        self.xbrli_entity = '{%s}entity' % ns_def['xbrli']
        self.xbrli_identifier = '{%s}identifier' % ns_def['xbrli']
        self.xbrli_period = '{%s}period' % ns_def['xbrli']
        self.xbrli_start_date = '{%s}startDate' % ns_def['xbrli']
        self.xbrli_end_date = '{%s}endDate' % ns_def['xbrli']
        self.xbrli_instant = '{%s}instant' % ns_def['xbrli']
        self.xbrli_forever = '{%s}forever' % ns_def['xbrli']
        self.xbrli_segment = '{%s}segment' % ns_def['xbrli']
        self.xbrldi_explicit_member = '{%s}explicitMember' % ns_def['xbrldi']
        self.xbrli_unit = '{%s}unit' % ns_def['xbrli']
        self.xbrli_measure = '{%s}measure' % ns_def['xbrli']
        self.xbrli_divide = '{%s}divide' % ns_def['xbrli']
        self.xbrli_unit_numerator = '{%s}unitNumerator' % ns_def['xbrli']
        self.xbrli_unit_denominator = '{%s}unitDenominator' % ns_def['xbrli']
        self.xsi_nil = '{%s}nil' % ns_def['xsi']

        # xsdファイルパス取得
        self.xsd = self.get_xsd_filepath()

        # コンテキストタグ(日付情報など)取得
        self.context_tags = self.get_context_tags()

        # ユニットタグ(単位情報)取得
        self.unit_tags = self.get_unit_tags()

        # データ取得(contextRef属性を持つタグを取得)
        self.xbrl_datas = self.get_xbrl_datas_contain_contextref()

        # 変数削除
        del self.root
        return

空白削除 & 改行統一

タグのテキスト部分に含まれる空白文字類や改行コードの処理を行うメソッドです。

    def strip_space_chars(self, text):
        """先頭と末尾の空白文字類を除去 & 改行コードを統一"""
        if isinstance(text, str):
            if self.strip_space:
                return RE_NEWLINE_SUB(self.newline, text.strip())
            else:
                return RE_NEWLINE_SUB(self.newline, text)
        return text

XBRLによっては、テキストの先頭と末尾に改行を入れて読み易くしているものがあります。ただ、そのような空白文字類は型変換の妨げになりますので、このメソッドで除去します。

また、テキスト部分の改行コードもXBRLによってまちまちですので、正規表現による置換で統一します。種々の改行コードは、以下のパターンでマッチします。

RE_NEWLINE_SUB = re_compile(r'\r\n|\r|\n').sub

並びも大切です。縦棒「|」で区切られたパターンは先頭から順に判定されますので、もっとも長い \r\n にマッチしなければ \r が試されて、それもマッチしなければ \n が試されます。これでマッチした改行コードを指定した改行コードで置換して統一します。

xsdファイルパス取得

スキーマファイル(xsd)のファイルパスを取得するメソッドです。

    def get_xsd_filepath(self):
        """xsdファイルパス取得"""
        element = self.root.find('.//%s' % self.link_schema_ref)
        if element is None:
            return None
        else:
            return element.get(self.xlink_href)

スキーマファイルにはラベルファイルなどのファイルパスが載っていますので、それらを読み込むときに使います。

コンテキスト取得

context タグを取得して辞書を作るメソッドです。勘定科目タグに日付やセグメント情報を付加するのに使います。

    def get_context_tags(self):
        """contextタグ取得"""
        od = OrderedDict()

        # contextタグ取得
        for element in self.root.findall('.//%s' % self.xbrli_context):
            # id属性の値を取得
            key_id = element.get('id')

            # idは重複しないはず
            assert key_id not in od

            # id属性をキーにして辞書を作成
            od[key_id] = OrderedDict()

            # entityタグ取得
            entity = OrderedDict()
            for (n, et_entity) in enumerate(
                    element.findall('.//%s' % self.xbrli_entity), start=1):
                # entityは通常1つ
                assert n == 1

                # identifierタグ取得
                entity.update(self.get_identifier_tags(et_entity))
                od[key_id]['entity'] = entity

            # periodタグ取得
            period = OrderedDict()
            for (n, et_period) in enumerate(
                    element.findall('.//%s' % self.xbrli_period), start=1):
                # periodは通常1つ
                assert n == 1

                # startDate, endDate, instant, foreverタグ取得
                period.update(self.get_date_tags(et_period))
                od[key_id]['period'] = period

            # segmentタグ取得
            segment = OrderedDict()
            for (n, et_segment) in enumerate(
                    element.findall('.//%s' % self.xbrli_segment), start=1):
                # segmentは通常1つ
                assert n == 1

                # explicitMemberタグ取得
                segment.update(self.get_explicit_member_tags(et_segment))
                od[key_id]['segment'] = segment
        return od

セグメント(segment)タグでは、勘定科目がどの表のどの列に属しているか、といった情報が取得できます。エディネットのシナリオ(scenario)タグと同じようなところです。

identifier 取得

コンテキストの中のアイデンティファイアー(identifier)タグを取得するメソッドです。

    def get_identifier_tags(self, element):
        """identifierタグ取得"""
        od = OrderedDict()
        for (n, et_identifier) in enumerate(
                element.findall('.//%s' % self.xbrli_identifier), start=1):
            # identifierは通常1つ
            assert n == 1

            for (name, value) in et_identifier.items():
                od[name] = value
            od['text'] = self.strip_space_chars(et_identifier.text)
        return od

EDGAR XBRL の場合は、scheme属性に "http://www.sec.gov/CIK"、テキストに個々の企業のセントラル・インデックス・キー(CIK)が入っているようです。

startDate, endDate, instant, forever 取得

コンテキストの中の開始日・終了日・期末日・フォーエバータグを取得するメソッドです。

    def get_date_tags(self, element):
        """日付タグ取得"""
        datas = OrderedDict()

        # startDateタグ取得
        et_start_date = element.find('.//%s' % self.xbrli_start_date)
        if et_start_date is not None:
            # startDateタグに属性は無いはず
            assert not et_start_date.attrib

            # startDateを追加
            datas['start_date'] = self.strip_space_chars(et_start_date.text)


            # endDateタグ取得
            et_end_date = element.find('.//%s' % self.xbrli_end_date)

            # endDateタグに属性は無いはず
            assert not et_end_date.attrib

            # endDateを追加
            datas['end_date'] = self.strip_space_chars(et_end_date.text)

        else:
            # instantタグ取得
            et_instant = element.find('.//%s' % self.xbrli_instant)
            if et_instant is not None:
                # instantタグに属性は無いはず
                assert not et_instant.attrib

                # instantを追加
                datas['instant'] = self.strip_space_chars(et_instant.text)

            else:
                # foreverタグ取得
                et_forever = element.find('.//%s' % self.xbrli_forever)

                # foreverタグに属性は無いはず
                assert not et_forever.attrib

                # foreverを追加
                datas['forever'] = self.strip_space_chars(et_forever.text)
        return datas

forever タグは <forever/> のように属性もテキストも持たないタグで、たとえば以下のXBRLで使われていました。

https://www.sec.gov/Archives/edgar/data/1341439/000119312510285703/orcl-20101130.xml

explicitMember 取得

コンテキストの中のエクスプリシット・メンバータグを取得するメソッドです。

    def get_explicit_member_tags(self, element):
        """explicitMemberタグ取得"""
        od = OrderedDict()
        for et_explicit_member in element.findall('.//%s' % self.xbrldi_explicit_member):
            key = et_explicit_member.get('dimension')

            assert key not in od

            od[key] = et_explicit_member.attrib
            od[key]['text'] = self.strip_space_chars(et_explicit_member.text)
        return od

unit 取得

ユニットタグを取得するメソッドです。

    def get_unit_tags(self):
        """unitタグ取得"""
        od = OrderedDict()

        # unitタグ取得
        for element in self.root.findall('.//%s' % self.xbrli_unit):
            # id属性の値を取得
            key_id = element.get('id')

            # idは重複しないはず
            assert key_id not in od

            # 直下のタグを取得
            for (n, et) in enumerate(element.findall('./{*}*'), start=1):
                if et.tag == self.xbrli_divide:
                    # divideタグ取得
                    od[key_id] = self.get_divide_tags(et)

                elif et.tag == self.xbrli_measure:
                    # measureタグに属性は無いはず
                    assert not et.attrib

                    # 名前空間接頭辞を取り除くためにコロンで分割
                    text = et.text.split(':')

                    # measureタグ取得
                    if len(text) == 1:
                        od[key_id] = text[0]
                    else:
                        od[key_id] = text[1]

                else:
                    raise ValueError('不正なタグ名 %s' % et.tag)
        return od

単位はメジャータグに入っています。「1株当たりの利益」のように割り算になっている単位では、割り算タグに分子タグと分母タグが入っていて、それらの中にメジャータグが入っています。

divide 取得

divide タグ取得を取得するメソッドです。

    def get_divide_tags(self, element):
        """divideタグ取得"""
        units = []
        for et in element.findall('./{*}*'):
            if et.tag == self.xbrli_unit_numerator:
                # unitNumeratorタグに属性は無いはず
                assert not et.attrib

                units.append(self.get_measure_tags(et, '*'))

            elif et.tag == self.xbrli_unit_denominator:
                # unitDenominatorタグに属性は無いはず
                assert not et.attrib

                units.append(self.get_measure_tags(et, '/'))

            else:
                raise ValueError('不正なunitタグ %s' % et.tag)

        # 単位は存在するはず
        assert units

        if units[0][0] == '*':
            # 先頭の不要な乗算記号を削除
            units[0] = units[0][1:]
        elif units[0][0] == '/':
            # 除算される1を追加
            units[0] = '1%s' % units[0][1:]

        return ''.join(units)

分子はユニット・ニューメレーター、分母はユニット・デノミネーターというタグに入っています。

このメソッドでは、それぞれを読み取るついでに乗算・除算記号を追加して、最後の return でひとつの文字列へと連結しています。

measure 取得

measure タグを取得するメソッドです。

    def get_measure_tags(self, element, operator):
        """measureタグ取得"""
        texts = []
        for (n, et) in enumerate(
                element.findall('.//%s' % self.xbrli_measure), start=1):
            # measureタグに属性は無いはず
            assert not et.attrib

            # テキストは存在するはず
            assert et.text is not None

            text = self.strip_space_chars(et.text).split(':')
            if len(text) == 1:
                # 名前空間接頭辞が無いパターン
                texts.append('%s%s' % (operator, text[0]))
            else:
                # 有るパターン
                texts.append('%s%s' % (operator, text[1]))
        return ''.join(texts)

単位を表すテキストですが、名前空間接頭辞が使われていたり、使われてなかったりしていました。そういうわけで、とりあえずコロン「:」で分割して、接頭辞は取り除いています。

EDGAR XBRLでは、1つの決算の中で結構色々な単位が使われていました。特に、その企業固有の業績を表す指標ですね、生産量とかユーザー数とか。このあたりも単位が設定されていました。

その関係か、同じ勘定科目でも単位違いのタグがあったりして、それらの区別にユニットタグも読み込む必要がありました。勘定科目はタグ名・コンテキスト・ID・ユニットをキーにして、ようやく大部分が特定できる感じです。

データ取得

勘定科目タグを読み込むメソッドです。実際の金額などを定義しているタグですね、これをすべて取得して辞書にしています。

    def get_xbrl_datas_contain_contextref(self):
        """データ取得(contextRef属性を含むタグをすべて取得する)"""
        datas = OrderedDict()
        for element in self.root.findall('.//{*}*[@contextRef]'):

            # テキストブロックをスキップ。ただしDEIはスキップしない。
            if self.skip_textblock:
                if self.ns_dei:
                    if self.ns_dei not in element.tag:
                        # deiの名前空間接頭辞を含まない
                        if 'TextBlock' in element.tag:
                            # TextBlockの文字列が含まれる
                            continue
                else:
                    if 'TextBlock' in element.tag:
                        continue

            if self.skip_text_len is not None:
                if element.text:
                    # 指定した文字数を超えるテキストを持っていたらスキップ
                    if len(element.text) > self.skip_text_len:
                        print('skipped len(text): %d' % len(element.text))
                        continue

            # tag名、contextRef、id、unitRefのタプルをキーにして辞書を作成
            key = (
                element.tag,
                element.get('contextRef'),
                element.get('id'),
                element.get('unitRef'),
                )
            data = OrderedDict()

            # 属性の内容を追加
            data.update(element.attrib)

            # テキストも追加
            data['text'] = self.strip_space_chars(element.text)

            if key in datas:
                if data == datas[key]:
                    # キーも値も同じなのでスキップ
                    continue
                else:
                    # キーが同じで値が異なる
                    print('warning キー重複 %s' % str(key))

            # リストに追加
            datas[key] = data
        return datas

contextRef を含むもので検索

勘定科目タグは contextRef 属性を含むもので検索するとうまく取れました。なので、そのようにしています。

はじめは dei, us-gaap, tickerシンボル などの名前空間を集めて取得するようにしていたのですが、使用頻度の低い名前空間がたくさんあることが分かって、contextRef で検索する方法に変えました。

テキストタグをスキップ

DEIでは TextBlock と付いたタグは見かけなかったのですが、一応スキップ対象から除外しています。

文字数でスキップしている部分ですが、EDGAR XBRLでは10万字、100万字を超えるテキストを持ったタグがたくさん登場します。タグに TextBlock と付いているか否かにかかわらずです。

これらを読み込むこと自体は全然OKなのですが、そういった巨大なテキストを CSV にすると、うまく読み込めないソフトが出てきます。たとえば Excel ですが、1つのセルが3万字くらいを超えると、その部分から表が崩れます。

おそらくは、セル内改行などを保持するためのクオーテーション記号が次のセルに送られてしまうためと思われるのですが、そういった問題を回避するために、巨大なテキストをスキップできるようにしています。

また、Python の csv モジュールでは大きな要素も読み込めましたが、それでも13万字くらいを超える場合では、以下のエラーが表示されました。

_csv.Error: field larger than field limit (131072)

これは、CSV を読み込む前に上限を引き上げておくと回避できました。具体的には、 csv.field_size_limit() に1000万くらいの値を渡して上限を設定します。

巨大なデータサイズさえ許容できれば、すべてテキスト付きで読み込んで、Excel で見たい時だけ、それ用に加工するのが良いと思います。

ダブったキーの処理

辞書にする時のキーですが、とりあえず tag名、contextRef、id、unitRef のタプルをキーにしています。

稀なのですが、「すべての属性が同じで金額違いのタグ」と「金額まで完全一致のタグ」も存在していました。たぶん、XBRLの表現力の限界か何かでこのようなタグが生成されているのではないかなと、想像しています。エディネットXBRLでも見かけました。

とりあえず、どちらの場合も区別する方法が無さそうだったので、ふたつ目以降はスキップしています。金額違いの場合は、一応、画面に表示するようにしています。

タイトルとURLをコピーしました