開発nvidia-ces

前日(2026-05-20)の /check-earnings ジョブで、NVDA Q1 FY2027 の 8-K を SEC EDGAR から拾い、CFO Commentary のガイダンス6行を Turso の earnings_guidance テーブルに UPSERT 済みだった。今日はその続きで、決算日に Google Chat へ流す通知フォーマットを設計し、最終的にターミナル表示まで一気通貫で動かす作業に入った。

途中で「Webhook やめてターミナルに出せばいい」と方針が変わったり、コンソールが cp932 で絵文字を出せずに止まったり、コンセンサスデータプロバイダの実績取り込みが間に合わずに自前で press release を叩きに行く羽目になったりした。その過程をメモしておく。

やりたかったこと

「NVDA の決算が出たら、コンセンサス予想と実績を並べて、ビート率まで一目で読める通知を出したい」。これが今日の出発点だった。

既存の nvidia-guidance-watcher/main.py は SEC EDGAR を 30 分おきに叩いて 8-K を検出し、新規ファイリングがあれば Google Chat に「8-K が出ました」とだけ通知する作りになっていた。検出はできても、肝心の「予想と実績の比較」がない。決算速報をスマホで見るとき、Revenue / EPS / Gross Margin / OpEx / Tax Rate がコンセンサスにどれだけ勝った(負けた)かを真っ先に知りたいので、そこを埋める。

参考にしたのは Apple / NVIDIA の決算速報メッセージで流れてくるフォーマット。Revenue・EPS・Gross Margin・OpEx・Tax Rate の5行に加えて、次四半期ガイダンスも予想対比で並べる構成にした。

ユーザーからの指示は明確だった。「予想と実績を比較してビート率も入れて。ガイダンスのビート率の方が重要」。ガイダンスは決算そのものより株価への影響が大きいので、そこは譲れない。

設計:純粋関数とI/Oを切り分ける

最初に Claude Code に既存 main.py を読ませて、組み込み箇所を決めた。

  • 既存の8-K検出ループはそのまま残す
  • 新規8-K が「決算 (Q1/Q2/Q3/Q4) の8-K」だった場合のみ、サマリー組み立てフックを呼ぶ
  • フォーマット組み立ては純粋関数として earnings_summary.py に切り出す
  • Turso からの予想取得、press release からの実績取得は副作用関数として別ファイルに置く

CLAUDE.md に「ロジック(純粋計算)と副作用(外部とのやりとり)は混ぜない」と書いてあるので、その方針に従う。earnings_summary.py は予想 dict と実績 dict を受け取って整形済み文字列を返すだけ。Turso にも HTTP にも触らない。

# earnings_summary.py(純粋関数のイメージ)
def build_earnings_summary(
    ticker: str,
    fiscal_period: str,
    consensus: dict,    # 外部のコンセンサスデータプロバイダ由来
    actual: dict,       # press release 由来
    guidance: dict,
) -> str:
    revenue_line = format_beat_line("売上高", actual["revenue"], consensus["revenue"])
    eps_line = format_beat_line("EPS", actual["eps"], consensus["eps"])
    ...
    return "\n".join([header, revenue_line, eps_line, ...])

format_beat_line は実績と予想を受け取って (実績) vs (予想) → ビート率 +x.x% の1行を返すだけの単純な関数。テストが書きやすい。

実装:Claude Code に書かせた

設計が決まってからは Claude Code に実装させた。

  1. earnings_summary.py を新規作成し、純粋関数として build_earnings_summaryformat_beat_line を定義
  2. main.py の8-K検出フックの直後に「決算ファイリングか判定 → 該当なら Turso から予想取得 → press release から実績取得 → サマリー組み立て → 通知」のフローを追加
  3. NVDA press release から requests で実績を取得する fetch_nvda_actuals 関数を別モジュールに追加(HTML から Revenue / EPS / Gross Margin を抜き出す)

press release のスクレイピングは安定しないので、本来は外部のコンセンサスデータプロバイダ側に実績が取り込まれるのを待ちたい。ただし今回は決算当日にプロバイダ側の取り込みが間に合っておらず、自前で取りに行くしかなかった。プロバイダの取り込みが終わったら、そちらを正にする運用に切り替える前提でコメントを残した。

つまずき1:コンソールが絵文字を出せない

ドライランで NVDA Q1 FY2027 の模擬データを流したら、Tursoからの予想取得は成功して値も帰ってきた。ところが print した瞬間に UnicodeEncodeError: 'cp932' codec can't encode character '\U0001f4ca' で落ちた。Windows のデフォルトコンソールは cp932 なので、📊 のような絵文字を出力できない。

仕様としては正しい挙動だが、こちらは絵文字を含むフォーマットをそのまま Google Chat に乗せたい。エンコード変換で剥がすのは本末転倒なので、Python 側で UTF-8 を強制した。

# 実行時に標準出力を UTF-8 に固定
$env:PYTHONIOENCODING = "utf-8"
python -X utf8 -m nvidia_guidance_watcher.main

これで 📊🟢 も問題なく出るようになった。本番(GitHub Actions)の Linux ランナーでは最初から UTF-8 なので問題にならないが、ローカル検証用に run.ps1 側で PYTHONIOENCODING=utf-8 をセットする運用にした。

つまずき2:Webhook やめてターミナル表示に切替

Webhook URL を earnings-dynamics-poc 側のリポジトリから探したが見つからず、nvidia-guidance-watcher の GitHub Secret に保存されていることがわかった。ローカルからは直接取れない。

ここでユーザーから方針変更が入った。「Webhook じゃなくてもいい。決算発表があった時だけターミナル上に表示してくれればいい」。確かに、最初に欲しいのは「フォーマットが意図通りに組まれているか」の確認であって、配信は後でいい。Google Chat への送信は Webhook を別途設定すれば後付けで足せる構造のままにして、まずは print_earnings_summary(notification) という関数を main.py に追加し、決算検知時のみコンソールに整形済みメッセージを出す形に変えた。

# main.py 抜粋(決算検知時のみターミナル表示)
if is_earnings_filing(filing):
    consensus = fetch_consensus_from_turso(ticker, fiscal_period)
    actual = fetch_nvda_actuals(filing.url)
    summary = build_earnings_summary(ticker, fiscal_period, consensus, actual, guidance)
    print_earnings_summary(summary)  # 標準出力に出すだけ

Webhook 送信関数は残してあるので、本番投入時は printpost_to_google_chat に差し替えるだけで済む。

統合テスト:模擬 notification dict で動作確認

実装後、模擬の notification dict(8-K の URL とメタデータ)を print_earnings_summary に渡して動作確認した。

  • Turso から Q1 FY2027 の予想5項目 + Q2 FY2027 のガイダンス予想を取得
  • NVDA IR ページに requests を投げて Q1 実績を取得
  • build_earnings_summary で Revenue / EPS / Gross Margin / OpEx / Tax Rate と Q2 ガイダンスを並べた整形文字列を組み立て
  • UTF-8 強制したコンソールに出力

絵文字付きの「決算日サマリー」がきれいに表示された。Google Chat に貼り付けても問題なく表示される構造だ。今日の作業時点でフォーマットはこのまま本番メッセージとして使える状態になった。

試行錯誤の記録

つまずき対応
Webhook URL が GitHub Secret にあってローカルから取れないWebhook 配信を後回しにし、まずターミナル表示で完成形を確認
コンソールが cp932 で絵文字を出せないPYTHONIOENCODING=utf-8-X utf8 で UTF-8 強制
外部のコンセンサスデータプロバイダに当日実績が間に合わないpress release から requests で直接取得する関数を追加
純粋関数と副作用の混在を避けたいearnings_summary.py は dict in / str out のみ、I/O は別ファイル

学び

  • 配信より先にフォーマットを完成させる。Webhook 設定で詰まったときに「ターミナル表示でいい」に切り替えてもらえたのは正解だった。コアの価値はフォーマット組み立て側にあって、配信先は差し替え可能なI/Oに過ぎない
  • 純粋関数とI/Oを切り分けると、検証が圧倒的に楽build_earnings_summary は dict を渡せば文字列が返るだけなので、Turso にも HTTP にも触らずに何度でも回せた。「実装が正しいか」を確認するのに本番データが要らない
  • Windows コンソールの cp932 は絵文字で詰むPYTHONIOENCODING=utf-8-X utf8 の組み合わせを run.ps1 に固定するのが定石。ローカル検証で毎回踏むので、テンプレ化して二度と詰まないようにした
  • 外部のコンセンサスデータプロバイダが間に合わないケースはある。今回は press release を直接叩いて埋めたが、本来はプロバイダの取り込みを待ちたい。実装の優先順位として「実績取得は最終手段としての自前スクレイピングを残しつつ、デフォルトはプロバイダ参照」にしておくのが安全

明日やること

  • print_earnings_summary のテストを pytest で書く(dict in → str out の純粋関数だけテストすれば十分)
  • fetch_nvda_actuals の HTML パース部分にスナップショットテストを足し、press release のフォーマット変更で壊れたら気づけるようにする
  • run.ps1 の冒頭に $env:PYTHONIOENCODING = "utf-8" を固定で書き、cp932 で二度と詰まらないようにする
  • 外部のコンセンサスデータプロバイダの「Q1 実績」取り込みタイミングをログから観測し、press release フォールバックをいつ切り替えられるか判断する
  • Google Chat Webhook を nvidia-guidance-watcher の Secret 経由で本番投入する切替手順をメモする

関連

  • 前日:/check-earnings で NVDA Q1 FY2027 8-K を SEC EDGAR から検出 → Turso earnings_guidance に6行 UPSERT
  • 関連プロジェクト:earnings-dynamics-poc / nvidia-guidance-watcher