見出し画像

ChatGPTに有価証券報告書読んでもらうWebアプリを作った!

有価証券報告書とか読みたいけど、長いし、専門用語がよくわからなくて読みたくなくなることがよくあるので、
ChatGPTに決算資料を読んでもらい要約してもらったり、質問に答えてもらったりできるアプリのプロトタイプを作ってみました。

アプリはこちら

https://finance-analysis.streamlit.app/


使い方

ご自身のOpenAI APIキーを入力してください。
入力しない場合、ChatGPTを使用したすべての回答は、テストとして、ChatGPTAPIを使ったときの結果になりますので、ご注意ください。

まずは期間を指定して、その期間に開示された書類の一覧を取得します。
(取得にはしばらく時間がかかります)


取得された企業数が20件以上の場合は選択画面を出すために検索します。

検索結果から、分析対象の企業を選択して、データを取得します。

しばらく時間がかかります、、、

終了後下の方にスクロールすると、取り出された要素が表示されます。

項目をクリックすると、その中身を確認できます。

要約するをクリックした場合は、しばらくすると次のように要約してくれました。
要約するのボタンはあらかじめプロンプトを指定しており、それに加えて、現在開いているコンテンツのテキストを送信しています。長いコンテンツの場合は、うまく要約できない場合があります。

次に質問をしてみます。
質問の部分は最初の1回目の質問には、質問の項目に入力した内容+現在開いているコンテンツのテキストが送信されるようになっています。2回目以降は1回目の会話もメッセージに含まれるため、質問内容のみ挿入する形になっています。
新しい取り組みがあるか質問してみましたが、該当部分はなかったようです。

次に2022年3月の体制について質問しました。こちらは該当部分を示してくれました。

使い方は以上になります。

他のexpandingの要約やChatGPTに質問するの部分には別のexpanding内部のコンテンツは含まれていません。例えば、沿革の部分でガバナンスに関する質問をしても正しく回答されませんので、ご注意ください。
数値データはうまく対応できない場合があります。
細かいバグやエラーの修正ができていない部分があります。

ChatGPTの回答は正しくない場合がありますので、利用の際はご注意ください。

アプリの概要

  1. 期間を指定して、EDINETから日々の開示資料一覧を取得

  2. その開示資料からほしい資料を検索できるようにする

  3. データを取得する企業を選択できるようにする

  4. 選択企業のXBRLデータをAPIで取得する

  5. XBRLを解析する(正直これが一番大変でした)

  6. XBRLを表示

  7. 表示したコンテンツごとにChatGPTとのやり取りができるように機能を追加


2023/9追記

このアプリを公開して、想像していた以上に様々な方から反応をいただきました。こういったアプリが欲しかったという意見や、情報を後悔してほしいといった意見が多く寄せられたため、元々公開する予定はありませんでしたが、今回、プロトタイプ版のアプリを公開することにしました。

このアプリの作成はかなりの時間と労力を使ったので、その中でも特に大変だった部分に関しては有料での公開となります。まず、このアプリの概要と大まかな機能について説明をしていきます。

アプリの概要

実際にこのアプリを使っていただければ分かるかと思いますが、このアプリは公開される有価証券報告書から情報を取り出してChatGPTにその内容を要約してもらったり、内容に基づいて質問したりできるようにすることを目的としたアプリです。有価証券報告書は自分で読もうと思うとかなり長いですし、様々な企業の報告書が日々公開されるので、情報収集が面倒だと思っていました。これをChatGPTで効率化できないかと思い作成しました。

このアプリは大きく三つの機能から構成されています。一つ目は公開されている有価証券報告書一覧を取得する機能。二つ目は特定の企業の有価証券報告書をダウンロードし、その内容を抽出して表示する機能。そして三つ目が、有価証券報告書の内容をChatGPTに投げて要約してもらったり、質問したりしてもらう機能です。

この中で特に大変だったのは、ChatGPTと組み合わせるよりも、EDINETから自分の欲しい情報を抜き出すところでした。EDINETに公開されている有価証券報告書はXBRL形式という特殊な形式をしており、そのファイルをうまく扱えるようになるために色々と勉強したり試行錯誤を繰り返す必要がありました。開発時間の約8割は、この部分の実装にかかったと思います。実際コードにしてみると大した分量ではないのですが、そこに至るまでにかなりの試行錯誤が必要でした。

XBRLの扱い方についてはまたいずれまとめたいと思います。ChatGPTを使う部分は基本的にAPIとのやり取りなので、それほど難しい部分はありませんでしたが、いい感じの回答を得るためにプロンプトを工夫しました。

アプリ全体の実装は簡単にWebアプリを作成できることから、今回はStreamlitというフレームワークを使いました。こちらのポイントとしては、Streamlitは毎回アクションが実行されるごとにページがリロードされてしまうので、そのままですと複数の関数を用いる場合には変数による値の保持ができません。そこで、上手くセッション機能を使って値を保持するというのがポイントです。

EDINET APIについて

今回のアプリを作成する上で、肝となるAPIである、EDINETのAPIについて簡単に説明します。EDINETのAPIは大きく二つのデータを取得することができます。一つ目は特定の日に公開された、有価証券報告書をはじめとする、開示資料の一覧を取得できるAPIです。このAPIを使うことで、いつどのような資料が公開されたのか知ることができます。そして中にはそれぞれの開示資料を区別するためのコードが含まれており、このコードを使うことで特定の資料をダウンロードできるようになります。

特定の企業の開示資料を取得するためにはまず初めに開示資料一覧を取得するAPIを使ってどういった開示資料が公開されているのか、その開示資料に対応するコードが何なのかを取得します。開示資料の一覧は、会社名などを検索することによって取得できるような形式ではなく、特定の日付に公開された一覧を取得することしかできないため、今回は開始日と終了日をユーザーに入力してもらい、その期間に公開された開示資料の一覧を取得するという方法を取りました。そして、その中から欲しい情報を得るために会社名を検索することができるようにしました。

有価証券報告書一覧の取得

まずはその部分のコードです。
今回作成したアプリのすべてのコードはapp.pyという一つのファイルに書き込んでいます。
その一部分をこれから紹介し、説明していきますが、コードの全体は最後にまとめてコピペすればいいようにしています。


# デフォルトの日付範囲を設定
default_end_date = datetime.date.today()
default_start_date = default_end_date - datetime.timedelta(days=30)

# 日付範囲の入力
start_date = st.date_input("開始日", value=default_start_date, key='start_date')
end_date = st.date_input("終了日", value=default_end_date, key='end_date')
# 「企業一覧を取得」ボタンがクリックされたかどうか、クリックされるとget_comp_listを実行
search_button_clicked = st.button("企業一覧を取得", on_click=get_comp_list)

get_comp_list関数を実行することで、一覧を取得しています。

# 企業一覧を取得し、日付範囲内のデータをデータフレームに格納する関数
def get_comp_list():
    # ユーザが選択した日付範囲を取得
    start_date = st.session_state['start_date']
    end_date = st.session_state['end_date']
    
    # 日付範囲から日付リストを作成する関数を呼び出し
    day_list = make_day_list(start_date, end_date)
    
    # 日付リストが有効な場合
    if day_list is not None:
        # 日付リストを利用してEDINETから企業情報を取得する関数を呼び出し
        securities_report_data = make_doc_id_list(day_list)

        # 取得した企業情報をデータフレームに格納
        df = pd.DataFrame(securities_report_data)
        
        # カラム名を変更して整理
        columns = {
            "filerName": "企業名_raw",
            "edinetCode": "EDINETコード",
            "secCode": "銘柄コード5桁",
            "docDescription": "報告書の種類"
        }
        df = df.rename(columns=columns)
        
        # 銘柄コードを整形して別のカラムとして追加
        df['銘柄コード'] = df['銘柄コード5桁'].astype(str).str[:4]
        
        # 企業名に含まれる全角文字を半角文字に正規化して整形
        df["企業名"] = df["企業名_raw"].apply(lambda x: unicodedata.normalize('NFKC', x))
        
        # データフレームをセッション状態に保存
        st.session_state['df'] = df
    
    # セッション状態の初期化
    st.session_state['search_docs'] = []
    st.session_state['nodes'] = []
    st.session_state['messages'] = {}
    st.session_state['user_msgs'] = {}
    st.session_state["expand"] = {}
    st.session_state["summary"] = {}

# 指定した日付範囲内の日付リストを生成する関数
def make_day_list(start_date, end_date):
    # 開始日から終了日までの日数を計算
    period = (end_date - start_date).days
    if period > 60:
        # 期間が60日を超える場合はエラーメッセージを表示してNoneを返す
        st.write("期間は60日以内にしてください。")
        return None

    # 開始日から終了日までの日付リストを生成して返す
    day_list = [start_date + datetime.timedelta(days=d) for d in range(period + 1)]
    return day_list

# EDINETから企業情報を取得する関数
def make_doc_id_list(day_list):
    # 企業情報を格納するリスト
    securities_report_data = []
    
    # 各日付についてEDINET APIからデータを取得
    for day in day_list:
        url = "https://disclosure.edinet-fsa.go.jp/api/v1/documents.json"
        params = {"date": day, "type": 2}

        res = requests.get(url, params=params)
        json_data = res.json()

        # 取得したデータから特定の条件を満たすものを抽出し、リストに追加
        for result in json_data["results"]:
            ordinance_code = result["ordinanceCode"]
            form_code = result["formCode"]

            if ordinance_code == "010" and form_code == "030000":
                securities_report_data.append(result)

    # 抽出した企業情報のリストを返す
    return securities_report_data

EDINET APIが2023年8月21日から変更となっており、しばらくは上記の旧APIも使えますが、新APIへの移行が必要です。アカウントの作成などは以下の公式を確認して作成後APIキーを取得してください。
https://disclosure2dl.edinet-fsa.go.jp/guide/static/disclosure/WZEK0110.html
新しいAPIの場合`make_doc_id_list`関数は以下のようになります。api_key部分に取得したAPIの文字列を入れてください。

api_key = "文字列"

def make_doc_id_list(day_list):
    # 企業情報を格納するリスト
    securities_report_data = []
    
    # 各日付についてEDINET APIからデータを取得
    for day in day_list:
        url = "https://disclosure.edinet-fsa.go.jp/api/v2/documents.json"
        params = {"date": day, "type": 2,"Subscription-Key":api_key}

        res = requests.get(url, params=params)
        json_data = res.json()

        # 取得したデータから特定の条件を満たすものを抽出し、リストに追加
        for result in json_data["results"]:
            ordinance_code = result["ordinanceCode"]
            form_code = result["formCode"]

            if ordinance_code == "010" and form_code == "030000":
                securities_report_data.append(result)

    # 抽出した企業情報のリストを返す
    return securities_report_data

取得したデータの一覧から、実際に詳細を確認する資料を選ぶため、検索結果や項目の中で結果が少なかった場合にはチェックボックスを追加して資料を選択してもらう機能を作ります。この部分はStreamlitだと少しレイアウトが崩れてしまいますが、1行ずつ表示をして、それに対してチェックボックスを表示するというアプローチで実装することができます。

# データフレームの長さが0でない場合
if len(df) != 0:
    # 表示するカラムのリスト
    display_columns = ["docID", "企業名", "EDINETコード", "銘柄コード", "報告書の種類"]
    
    # 指定されたカラムのみを表示
    st.write(df.loc[:, display_columns])
    
    # ユーザによる検索キーワードの入力を受け付けるテキスト入力欄を表示
    search_key = st.text_input("銘柄コードまたは企業名を入力してください")
    
    # 選択された行を保持するためのリスト
    selected_rows = []

    # もし検索キーワードが入力された場合
    if search_key:
        # 全角文字を半角文字に正規化
        search_key = unicodedata.normalize('NFKC', search_key)
        
        # データフレーム内でキーワードに合致する行を検索
        search_result = df[df["銘柄コード"].str.contains(search_key, case=False) |
                           df["企業名"].str.contains(search_key, case=False)]
    else:
        # もしキーワードが入力されていない場合は、全ての行を表示
        search_result = df
    
    # 検索結果の表示が20件以下の場合
    if len(search_result) <= 20:
        for index, row in search_result.iterrows():
            # 各行に対してチェックボックスを表示
            selected = st.checkbox(label="ボックス", value=False, key=index, label_visibility='hidden')
            if selected:
                selected_rows.append(index)  # 選択された行のインデックスをリストに追加
            st.write(search_result.loc[[index], display_columns])
    else:
        # 検索結果が多すぎる場合の処理
        st.write('表示件数が多すぎるため、選択ボックスを表示できません。検索して絞り込んでください。')
        st.write(search_result.loc[:, display_columns])

    # 選択された行があれば、その行を表示
    if selected_rows:
        st.write("選択された行(現在は1件ずつしか分析できません):")
        st.write(search_result.loc[selected_rows, display_columns])
        # 選択された行のdocIDをセッション状態に保存
        st.session_state['search_docs'] = search_result.loc[selected_rows, 'docID']

有価証券報告書のダウンロード

ここで選択されたコードを使って、二つ目のAPIにアクセスして有価証券報告書が含まれたZIPファイルをダウンロードします。その関数が以下になります。

def download_zip_file(doc_id):
    url = f"https://disclosure.edinet-fsa.go.jp/api/v1/documents/{doc_id}"
    params = {"type": 1}
    filename = f"{doc_id}.zip"
    # 解凍先のディレクトリのパス
    extract_dir = filename.replace('.zip','')

    res = requests.get(url, params=params, stream=True)
    if res.status_code == 200:
        with open(filename, 'wb') as file:
            for chunk in res.iter_content(chunk_size=1024):
                file.write(chunk)
        print(f"ZIP ファイル {filename} のダウンロードが完了しました。")
        # ZipFileオブジェクトを作成
        zip_obj = zipfile.ZipFile(filename, 'r')

        # 解凍先ディレクトリを作成(存在しない場合)

        if not os.path.exists(extract_dir):
            os.makedirs(extract_dir)
        # 全てのファイルを解凍
        zip_obj.extractall(extract_dir)
        # ZipFileオブジェクトをクローズ
        zip_obj.close()
        print(f"ZIP ファイル {filename} を解凍しました。")
    else:
        print("ZIP ファイルのダウンロードに失敗しました。")

新しいAPIの場合は、ほとんど同じですが、以下になります。

def download_zip_file(doc_id):
    url = f"https://disclosure.edinet-fsa.go.jp/api/v2/documents/{doc_id}"
    params = {"type": 1,"Subscription-Key":api_key}
    filename = f"{doc_id}.zip"
    # 解凍先のディレクトリのパス
    extract_dir = filename.replace('.zip','')

    res = requests.get(url, params=params, stream=True)
    if res.status_code == 200:
        with open(filename, 'wb') as file:
            for chunk in res.iter_content(chunk_size=1024):
                file.write(chunk)
        print(f"ZIP ファイル {filename} のダウンロードが完了しました。")
        # ZipFileオブジェクトを作成
        zip_obj = zipfile.ZipFile(filename, 'r')

        # 解凍先ディレクトリを作成(存在しない場合)

        if not os.path.exists(extract_dir):
            os.makedirs(extract_dir)
        # 全てのファイルを解凍
        zip_obj.extractall(extract_dir)
        # ZipFileオブジェクトをクローズ
        zip_obj.close()
        print(f"ZIP ファイル {filename} を解凍しました。")
    else:
        print("ZIP ファイルのダウンロードに失敗しました。")

ZIPファイルを解凍する機能と、ファイルを解析した後にファイルが大量に蓄積されてしまうことを防ぐため、ファイルの削除を行います。

def del_zip(doc_id):
    # フォルダの削除
    folder_name = doc_id
    shutil.rmtree(folder_name)

    # ファイルの削除
    file_name = f"{doc_id}.zip"
    os.remove(file_name)
    print('ダウンロードデータを削除しました')

これらの一連の流れをまとめたのが以下です。

def download():
    st.session_state['nodes'] = []
    st.session_state['messages'] = {}
    st.session_state['user_msgs'] = {}
    st.session_state["expand"] = {}
    st.session_state["summary"] = {}
    if len(st.session_state['search_docs']) > 1:
        st.write('1件のみ選択してください')
    else:
        for doc_id in st.session_state['search_docs']:
            download_zip_file(doc_id)
            root_node = get_node(doc_id)
            st.session_state['nodes'].append(root_node)
            del_zip(doc_id)
if len(st.session_state['search_docs']) != 0:
    search_button_clicked = st.button("選択したデータを取得", on_click=download)

選択したデータを取得をクリックすると実行されます。

これで分析を行いたい企業の有価証券報告書が入ったファイルを手に入れることができました。

ここで取得したXBRLから単純に特定の情報を取得するだけであれば、Qnameと呼ばれる取得したい項目のラベルIDのような値を指定することによって取得することができます。
しかし、問題はこのラベルが必ずしも全ての企業で統一されているものではなく、独自の定義が存在していたり、同じ意味の項目でも複数の名称が存在していたりするので、XBRLを扱う上で難しい問題になります。その解決方法については、有料公開となります。

XBRLファイルの解析

それでは、今回のアプリで最も肝となる企業のXBRLの解析方法について説明していきます。先程も少し説明したように、XBRLファイルはどういった情報なのかを区別するラベルが存在していますが、そのラベルが同じ意味でも異なるIDが使われていたりするため、解析が難しいというところがあります。
たくさんのXBRLファイルを解析することによってそのタグがどの内容を示しているのか、日本語の意味を取得することができますが、これらを自力で全て解析しようとするのはかなり手間がかかります。

そこで、様々な情報を探したところ、Pythonで使えるArelleというライブラリがXBRLの解析に非常に便利であることが分かりました。このライブラリはXBRLを解析して、それに付随するラベル定義のファイルを読み込んでくれるので、どういった項目が含まれているのかを取得することができます。

このArelleに関して、簡易的に使っている日本語の記事は存在しているのですが、あまり細かい部分を紹介している記事は存在していませんでした。そこで、ライブラリそのもののドキュメントを読んでみたのですが、こちらにもあまり細かい情報は書かれておらず、最終的にはライブラリのコードを読んで使い方を理解していきました。その結果として作成したコードがこれから説明していくコードになります。Arelleを使うことによって、XBRLファイルに登録されているすべての要素を読み込むことができます。

ここまでの内容は少し調べればできる内容なので、無料で提供していますが、ここから先の部分に関してはかなり情報が少なく、時間がかかった部分になるので有料で提供します。
また、おまけとして、APIを使わなくても、コンテンツのコピー機能を実装することで、Web版を使って無料でChatGPTに内容を質問したり、要約したりできるようにした機能を加えた場合も最後に紹介しています。
こちらはpyperclipが使えないといけないのですが、StreamlitCloudでは使えないようなので、デモのアプリには実装されていません。環境によっては動作しない可能性がありますが、これを使うことで、ChatGPT APIの費用を払う必要がなくなるので、無料で有価証券報告書の分析も可能になります。
かなり文字数が多くなっていますが、コード全体を示していたり、解説を入れたりしているためです。

ここから先は

48,898字 / 9画像

¥ 6,000

この記事が気に入ったらサポートをしてみませんか?