LLMによる決算報告書・IR資料の自動分析と売買シグナル生成

自然言語処理自動売買決算分析Pythonセンチメント分析

戦略概要

本戦略は、LLM(Large Language Model)の登場によって初めて実現可能になったアプローチです。決算短信、有価証券報告書、IR説明会資料などの非構造化テキストをLLMで自動分析し、売買シグナルを生成します。

LLMでなければ実現できなかった理由

従来のNLP(自然言語処理)技術では、以下の処理が困難でした:

処理従来NLPLLM
非構造化テキストの意味理解辞書ベース・限定的文脈を含めた深い理解
ニュアンスの把握(「堅調」vs「底堅い」)困難微妙な差異を識別可能
数値+テキストの統合分析別々に処理一体的に判断
新しい文書形式への対応再学習が必要ゼロショットで対応
経営者のトーン分析単語頻度に依存意図・懸念を推測

従来NLPとLLMの技術的差異

従来NLPのアプローチ(2020年以前)

# 従来NLP: 辞書ベースのセンチメント分析
from collections import Counter

# 事前定義された辞書
POSITIVE_WORDS = {"増益", "成長", "好調", "堅調", "拡大", "過去最高"}
NEGATIVE_WORDS = {"減益", "下方修正", "悪化", "減少", "懸念", "不透明"}

def traditional_sentiment(text: str) -> float:
    words = text.split()
    pos_count = sum(1 for w in words if w in POSITIVE_WORDS)
    neg_count = sum(1 for w in words if w in NEGATIVE_WORDS)
    total = pos_count + neg_count
    if total == 0:
        return 0.0
    return (pos_count - neg_count) / total

# 問題点:
# - 「増益だが先行き不透明」→ 文脈を無視
# - 「減益幅は縮小」→ ポジティブなのにネガティブと判定
# - 新しい表現への対応不可

LLMによるアプローチ

import anthropic
import json
from dataclasses import dataclass

@dataclass
class EarningsAnalysis:
    """決算分析結果"""
    sentiment_score: float  # -1.0 ~ 1.0
    surprise_level: str     # "positive_surprise", "inline", "negative_surprise"
    key_factors: list[str]
    risk_factors: list[str]
    guidance_change: str    # "raised", "maintained", "lowered", "withdrawn"
    recommended_action: str # "buy", "hold", "sell"
    confidence: float       # 0.0 ~ 1.0

def analyze_earnings_with_llm(
    earnings_text: str,
    company_name: str,
    client: anthropic.Anthropic
) -> EarningsAnalysis:
    """LLMで決算資料を分析"""

    prompt = f"""以下は{company_name}の決算短信からの抜粋です。
投資判断のための分析を行ってください。

## 決算資料
{earnings_text}

## 分析要件
1. センチメントスコア (-1.0〜1.0): 全体的なトーンを数値化
2. サプライズ判定: 市場予想との比較(positive_surprise/inline/negative_surprise)
3. 主要ポジティブ要因: 最大3つ
4. リスク要因: 最大3つ
5. ガイダンス変更: raised/maintained/lowered/withdrawn
6. 推奨アクション: buy/hold/sell
7. 判断の確信度 (0.0〜1.0)

## 分析時の注意
- 「底堅い」は控えめなポジティブ、「堅調」は明確なポジティブ
- 数値だけでなく経営者のトーンも考慮
- 一時的要因と構造的要因を区別
- 前年同期比だけでなく、前四半期比やガイダンスとの比較も考慮

JSON形式で回答してください。"""

    response = client.messages.create(
        model="claude-sonnet-4-20250514",
        max_tokens=2000,
        messages=[{"role": "user", "content": prompt}]
    )

    result = json.loads(response.content[0].text)

    return EarningsAnalysis(
        sentiment_score=result["sentiment_score"],
        surprise_level=result["surprise_level"],
        key_factors=result["key_factors"],
        risk_factors=result["risk_factors"],
        guidance_change=result["guidance_change"],
        recommended_action=result["recommended_action"],
        confidence=result["confidence"]
    )

決算短信PDFの解析パイプライン

import pdfplumber
from pathlib import Path

def extract_text_from_pdf(pdf_path: str) -> dict[str, str]:
    """決算短信PDFからセクション別にテキストを抽出"""

    sections = {
        "summary": "",      # 決算サマリー(1ページ目)
        "outlook": "",      # 業績予想・見通し
        "segment": "",      # セグメント情報
        "risk": "",         # リスク要因
        "md_and_a": "",     # 経営者による分析
    }

    with pdfplumber.open(pdf_path) as pdf:
        full_text = ""
        for page in pdf.pages:
            text = page.extract_text() or ""
            full_text += text + "\n"

    # セクション分割ロジック(簡略化)
    # 実際には正規表現やヘッダー検出で分割
    sections["summary"] = full_text[:3000]  # 最初の部分

    # 「業績予想」「見通し」を含むセクションを抽出
    if "業績予想" in full_text:
        idx = full_text.index("業績予想")
        sections["outlook"] = full_text[idx:idx+2000]

    return sections

def build_analysis_pipeline(
    pdf_path: str,
    client: anthropic.Anthropic
) -> EarningsAnalysis:
    """決算PDF分析のフルパイプライン"""

    # 1. PDFからテキスト抽出
    sections = extract_text_from_pdf(pdf_path)

    # 2. 重要セクションを結合
    analysis_text = f"""
## 決算サマリー
{sections['summary']}

## 業績予想
{sections['outlook']}
"""

    # 3. LLMで分析
    result = analyze_earnings_with_llm(
        analysis_text,
        company_name="分析対象企業",
        client=client
    )

    return result

Post-Earnings Announcement Drift(PEAD)との組み合わせ

決算発表後のアノマリー(PEAD)は、決算サプライズ後に株価が同方向にドリフトし続ける現象です。LLM分析とPEADを組み合わせることで、より精緻な戦略が構築できます。

from datetime import datetime, timedelta
import yfinance as yf

@dataclass
class TradingSignal:
    """売買シグナル"""
    ticker: str
    action: str           # "buy", "sell", "hold"
    entry_price: float
    target_price: float
    stop_loss: float
    holding_period_days: int
    confidence: float
    reasoning: str

def generate_pead_signal(
    ticker: str,
    analysis: EarningsAnalysis,
    current_price: float
) -> TradingSignal:
    """PEAD戦略に基づく売買シグナル生成"""

    # PEADの典型的なドリフト期間: 60-90日
    holding_period = 60

    if analysis.surprise_level == "positive_surprise" and analysis.sentiment_score > 0.3:
        # ポジティブサプライズ → ロング
        # 過去の研究では平均+2-4%のドリフト
        expected_drift = 0.03 * analysis.confidence

        return TradingSignal(
            ticker=ticker,
            action="buy",
            entry_price=current_price,
            target_price=current_price * (1 + expected_drift),
            stop_loss=current_price * 0.95,  # 5%ストップロス
            holding_period_days=holding_period,
            confidence=analysis.confidence,
            reasoning=f"ポジティブサプライズ検出。主要因: {', '.join(analysis.key_factors[:2])}"
        )

    elif analysis.surprise_level == "negative_surprise" and analysis.sentiment_score < -0.3:
        # ネガティブサプライズ → ショート or アボイド
        return TradingSignal(
            ticker=ticker,
            action="sell",
            entry_price=current_price,
            target_price=current_price * 0.97,
            stop_loss=current_price * 1.03,
            holding_period_days=holding_period,
            confidence=analysis.confidence,
            reasoning=f"ネガティブサプライズ検出。リスク要因: {', '.join(analysis.risk_factors[:2])}"
        )

    else:
        return TradingSignal(
            ticker=ticker,
            action="hold",
            entry_price=current_price,
            target_price=current_price,
            stop_loss=current_price * 0.95,
            holding_period_days=0,
            confidence=analysis.confidence,
            reasoning="明確なサプライズなし。様子見推奨。"
        )

コスト試算

LLMを使った決算分析のAPI利用料を試算します。

項目Claude SonnetGPT-4o
1決算あたり入力トークン約4,000約4,000
1決算あたり出力トークン約500約500
入力単価 (/1M tokens)$3.00$2.50
出力単価 (/1M tokens)$15.00$10.00
1決算あたりコスト約$0.02約$0.015
日経225全銘柄/四半期約$4.50約$3.40
年間(4四半期)約$18約$14

コスト最適化のポイント:

  • バッチ処理でAPI呼び出しを効率化
  • 重要銘柄のみ詳細分析、他は簡易分析
  • キャッシュ機能で同一決算の再分析を防止

ハルシネーション対策

LLMは事実と異なる情報を生成するリスク(ハルシネーション)があります。決算分析では以下の対策が必須です。

def validate_analysis(
    analysis: EarningsAnalysis,
    actual_numbers: dict
) -> tuple[bool, list[str]]:
    """分析結果の検証"""

    errors = []

    # 1. 数値の整合性チェック
    if "増益" in str(analysis.key_factors):
        if actual_numbers.get("yoy_profit_change", 0) < 0:
            errors.append("増益と判定したが実際は減益")

    # 2. ガイダンス変更の検証
    # 実際のガイダンス数値と照合

    # 3. 信頼度が低い場合は警告
    if analysis.confidence < 0.5:
        errors.append(f"分析の確信度が低い: {analysis.confidence}")

    return len(errors) == 0, errors

# 複数モデルでのクロスチェック
def cross_validate_with_multiple_models(
    text: str,
    company_name: str
) -> EarningsAnalysis:
    """複数LLMで分析し、結果を比較"""

    # Claude, GPT-4, Geminiで並行分析
    # 結果が大きく異なる場合は警告
    # 過半数が一致した判定を採用
    pass

推奨される検証フロー

  1. 数値抽出の検証: LLMが抽出した数値を元資料と照合
  2. センチメントの妥当性: 極端なスコア(±0.8以上)は人間が確認
  3. サプライズ判定の検証: コンセンサス予想との実際の乖離率と比較
  4. 複数モデル検証: 重要な判断は2つ以上のLLMで確認

バックテスト結果と注意点

2023-2025年の日経225構成銘柄でバックテストを実施した場合の想定結果:

指標LLM戦略単純PEADベンチマーク
年率リターン15-20%10-12%8%
シャープレシオ1.2-1.50.90.6
最大ドローダウン-15%-18%-22%
勝率58-62%54%-

重要な注意点:

  1. ルックアヘッドバイアス: 決算発表時刻を正確に考慮(場中発表 vs 引け後発表)
  2. 執行コスト: 決算直後はスプレッドが拡大、スリッページを考慮
  3. 流動性制約: 小型株は分析精度が高くても執行困難
  4. モデルドリフト: LLMの更新で分析傾向が変化する可能性
  5. 過学習リスク: 特定期間に最適化しすぎない

実装のベストプラクティス

import asyncio
from datetime import datetime
import logging

class EarningsAnalyzer:
    """本番運用向け決算分析システム"""

    def __init__(self, client: anthropic.Anthropic):
        self.client = client
        self.logger = logging.getLogger(__name__)
        self.cache = {}  # 分析結果キャッシュ

    async def analyze_batch(
        self,
        earnings_list: list[dict]
    ) -> list[EarningsAnalysis]:
        """複数決算を並行分析"""

        tasks = []
        for earnings in earnings_list:
            # キャッシュチェック
            cache_key = f"{earnings['ticker']}_{earnings['date']}"
            if cache_key in self.cache:
                self.logger.info(f"Cache hit: {cache_key}")
                continue

            tasks.append(self._analyze_single(earnings))

        results = await asyncio.gather(*tasks, return_exceptions=True)

        # エラーハンドリング
        valid_results = []
        for r in results:
            if isinstance(r, Exception):
                self.logger.error(f"Analysis failed: {r}")
            else:
                valid_results.append(r)

        return valid_results

    async def _analyze_single(self, earnings: dict) -> EarningsAnalysis:
        """単一決算の分析(リトライ付き)"""

        for attempt in range(3):
            try:
                result = analyze_earnings_with_llm(
                    earnings["text"],
                    earnings["company_name"],
                    self.client
                )

                # 検証
                is_valid, errors = validate_analysis(
                    result,
                    earnings.get("actual_numbers", {})
                )

                if not is_valid:
                    self.logger.warning(f"Validation errors: {errors}")

                return result

            except Exception as e:
                self.logger.warning(f"Attempt {attempt+1} failed: {e}")
                await asyncio.sleep(2 ** attempt)

        raise RuntimeError("All attempts failed")

まとめ

LLMによる決算分析は、従来のNLP手法では実現できなかった「文脈を理解した投資判断」を可能にします。ただし、ハルシネーションリスクへの対策、適切なバックテスト、執行コストの考慮が不可欠です。

推奨される導入ステップ:

  1. まず少数銘柄(10-20社)で分析精度を検証
  2. 人間の判断との一致率を測定
  3. ペーパートレーディングで執行タイミングを検証
  4. 小ロットで実運用開始、段階的にスケールアップ

LLMは強力なツールですが、最終判断には人間の監督が推奨されます。特に、異常なセンチメントスコアや低信頼度の分析結果は必ず人間が確認してください。