はじめに
Databricks Free Edition(無料)で、東証上場株のスクリーニングシステムを作った記録の第2部(実践編)です。
| 記事 | 内容 |
|---|---|
| 第1部 基礎編(別記事) | 題材に依存しない Databricks の最小サンプル。Spark・Delta Lake・Unity Catalog・MLflow・AI/BI と Genie を、公開サンプルデータで順に触る |
| 第2部 実践編(本記事) | 第1部の機能を使って、東証上場株を週次で自動スクリーニングし、AI 付きの中長期保有候補を作る |
前提: 第1部 基礎編(別記事)を読んでいる前提で書いています。Spark・Delta Lake・Unity Catalog・MLflow・Genie の基本は第1部で扱ったため、本記事では改めて説明しません。本文中の「第1部第N章」は、第1部の該当章を指します。
作るもの: JPX の銘柄一覧と yfinance の株価・財務データを取り込み(Bronze)、テクニカル指標を計算し(Silver)、スクリーニングとスコアリングで候補を絞り(Gold)、これを週次ジョブで回します。そのうえで、Genie、独自のエージェント、ダッシュボードでこの結果を眺めたり聞いたりできるようにします。
進め方: 章ごとにノートブックを1本ずつ作ります。掲載コードは省略せず載せ、全ノートブックとエージェントのソースは末尾の付録A・付録B にまとめています。
章立て
- 第0章 環境準備 — 実践編の器を作り、外部接続を確かめる
- 第1章 Bronze層 — 生データ取り込み
- 第2章 Silver層 — 特徴量計算
- 第3章 Gold層 — スクリーニングとスコアリング
- 第4章 定期実行・運用設定 — 週次パイプラインに載せる
- 第5章 Genie 簡易エージェント — 自然言語でスクリーニング結果に問う
- 第6章 LLMOps 独自エージェント — 作って、測って、公開する
- 第7章 可視化 — 聞かずに眺めるダッシュボード
- 終章 — 動くものを、継続的に良くする
本記事をお読みになる前に
- 本記事は、Databricks 上で実際に作ってみた内容を記録としてまとめたものです。体系的な解説書やベストプラクティス集ではありません。
- 本文は、実装(実際に動かしたコード)を元に AI を使って執筆しています。内容は確認していますが、誤りや不自然な表現が残っている可能性があります。
- 掲載コードは実機(Databricks Free Edition)で動作確認していますが、Databricks はバージョンアップが頻繁で、画面・機能・仕様は予告なく変わります。今後も同じように動作することは保証できません。
- 本記事の東証スクリーニングやスコアリングのロジックは、仕組みを試すために簡易的に作ったものです。計算や判定は正確とは限らず、投資判断の根拠にはなりません。投資は自己責任でお願いします。
第0章 環境準備 — 実践編の器を作り、外部接続を確かめる
第1部では、基礎編の器として fundamentals カタログと fundamentals.demo スキーマを作りました
(第1部第1章)。第2部で扱う東証スクリーニングは、そことは別のカタログ stock_analysis に
成果物を貯めていきます。この章では、次章から使う器を先に用意します。
あわせて、この章では外部への疎通を先に確かめます。第2部は Free Edition の外にある
データ(yfinance の株価、JPX の銘柄一覧)と、ワークスペース内部の AI(Foundation Model API)に
依存します。どれか1つでも届かないと、次章以降は途中で止まります。動かない原因を、実装の
最中ではなく最初に切り分けておくための章です。
この章のノートブック:
notebooks/00_setup.py(本章のコードは全文このノートブックと一致します)
カタログ規約: この章で作るのはstock_analysisカタログとbronze/silver/goldの3スキーマ
章の流れは、ノートブックの並びそのままです。
- 依存ライブラリを入れる
- カタログとスキーマを作る
- yfinance に届くか確かめる
- JPX の Excel ファイルに届くか確かめる
- Foundation Model API に届くか確かめる
- まとめて完了チェックする
依存ライブラリを入れる
第2部で使う外部ライブラリを先頭のセルで入れます。
%pip install yfinance pandas-ta requests openpyxl -q
pandas-taのインストールに失敗したら、それは環境の Python バージョンが原因です。
原因と対処は**次章(第2部 第1章 Bronze 層)**の章末コラム
「サーバーレスの Python バージョンとpandas-ta(Free Edition の落とし穴)」にまとめてあります。
先にそちらを読んで環境バージョンを上げてから、この章に戻ってきてください。
入ったことを、バージョンを表示して確かめます。インストールできたかではなく
import できるかを見るのが確実です。
import yfinance as yf
import pandas as pd
import pandas_ta as ta
import requests
from pyspark.sql import SparkSession
print(f"yfinance: {yf.__version__}")
print(f"pandas: {pd.__version__}")
print(f"pandas-ta: {ta.version}")
print("pyspark: OK")
print("\n✅ ライブラリインポート完了")
カタログとスキーマを作る
ここがこの章の本体です。第2部のすべてのテーブルは
stock_analysis.<schema>.<table> の3部構成で書くので、その catalog と schema が
先に存在していなければなりません。テーブルは書き込み時に自動で作られますが、
カタログとスキーマは自動では作られません。
まず、いまワークスペースにどんなカタログがあるかを見ます。
# カタログの確認
spark.sql("SHOW CATALOGS").show()
一覧に stock_analysis が無くても問題ありません。次のセルで作ります。第1部第1章で
fundamentals を作ったときと同じ %sql と IF NOT EXISTS の組み合わせなので、
**何度実行しても安全(冪等)**です。
%sql
CREATE CATALOG IF NOT EXISTS stock_analysis
COMMENT '東証株スクリーニング(実践編)';
CREATE SCHEMA IF NOT EXISTS stock_analysis.bronze
COMMENT 'Bronze layer - raw stock data (TSE)';
CREATE SCHEMA IF NOT EXISTS stock_analysis.silver
COMMENT 'Silver layer - cleansed and feature-enriched data';
CREATE SCHEMA IF NOT EXISTS stock_analysis.gold
COMMENT 'Gold layer - screening results and scores';
3つのスキーマ名が、そのままメダリオンアーキテクチャの層(第1部第3章)になっています。
器の名前が設計を語っている状態にしておくと、後から見た人にも
「どのテーブルがどの段階のものか」が名前だけで伝わります。
作成できたかを確認します。
# スキーマ一覧確認
spark.sql("SHOW SCHEMAS IN stock_analysis").show()
bronze / silver / gold の3つが並べば成功です。ここから先の章は、この3つが
存在することを前提に進みます。
なぜカタログを分けるのかは第1部第4章「なぜ
fundamentalsとstock_analysisを
分けるのか」で扱いました。カタログはガバナンスと資産分離の境界なので、学習用の実験が
実践編のデータを汚さないよう、最初から別の器にしておきます。
yfinance に届くか確かめる
株価と財務データの取得元である yfinance に届くかを、代表銘柄1つで確かめます。
全銘柄を取りにいく前に、1銘柄で経路を確認するのが定石です。
try:
ticker = yf.Ticker("7203.T")
hist = ticker.history(period="5d")
if hist.empty:
print("⚠️ データが空です。Databricks Free Edition のアウトバウンド制限の可能性があります。")
print("→ 代替手段: ローカルPCでデータ収集 → Volumes 経由でアップロード")
else:
print(f"✅ yfinance 疎通OK: {len(hist)}行取得")
display(hist.tail())
except Exception as e:
print(f"❌ yfinance エラー: {e}")
print("→ 代替手段: ローカルPCでデータ収集 → Volumes 経由でアップロード")
7203.T はトヨタ自動車です。yfinance は米国式のティッカー表記を使うので、証券コードに
.T(東証)を付けて渡します。この表記は次章でも全銘柄に対して使います。
最後の行の display() は Databricks ノートブックが用意している表示関数で、DataFrame を
並べ替え・絞り込みのできる表として描画します(print() はテキストのまま出力します)。
Databricks ノートブックの外では使えない点に注意してください。
注目してほしいのは、失敗のしかたが2通りあることです。例外が飛ぶ場合(except 側)と、
例外は飛ばないが中身が空で返る場合(hist.empty)があり、後者は見落としやすいので明示的に
分岐しています。どちらの場合も、代替手段を同じ文言で案内しています。
Free Edition はアウトバウンド通信に制限があり、ここで届かないことがあります。その場合は
ローカル環境でデータを取得し、Unity Catalog の Volumes 経由でアップロードしてください。
取り込み以降の処理は、データの入手経路によらず同じです。
JPX の Excel ファイルに届くか確かめる
次に、銘柄マスタの取得元である日本取引所グループ(JPX)の公開ファイルに届くかを確かめます。
JPX_URL = "https://www.jpx.co.jp/markets/statistics-equities/misc/tvdivq0000001vg2-att/data_j.xlsx"
try:
response = requests.get(JPX_URL, timeout=30)
print(f"HTTPステータス: {response.status_code}")
if response.status_code == 200:
print(f"✅ JPX Excel ダウンロードOK: {len(response.content):,} bytes")
# 読み込みテスト
import io
df_test = pd.read_excel(io.BytesIO(response.content))
print(f" 行数: {len(df_test)}, 列数: {len(df_test.columns)}")
print(f" 列名: {df_test.columns.tolist()}")
else:
print(f"⚠️ HTTPエラー: {response.status_code}")
except Exception as e:
print(f"❌ JPX Excel 取得エラー: {e}")
print("→ 代替手段: ローカルPCでダウンロード → Volumes 経由でアップロード")
ここではダウンロードできるかだけでなく、pandas で読めるかまで確かめています。
届いても中身が読めなければ意味がないからです。列名まで表示しているのは、次章で
この Excel ファイルの10列を英語名にリネームするときに、現在の列構成が想定どおりかを先に
目で見ておくためです。JPX 側のフォーマットが変われば、ここで気付けます。
実際に走らせると、読み込みテストの部分は次のように表示されます
(行数 は上場銘柄数なので、ダウンロードするバイト数ともども実行する時期で前後します)。
HTTPステータス: 200
行数: 4441, 列数: 10
列名: ['日付', 'コード', '銘柄名', '市場・商品区分', '33業種コード', '33業種区分', '17業種コード', '17業種区分', '規模コード', '規模区分']
この10列がこの順で並んでいれば想定どおりです。列名や列数が変わっていたら、
次章のリネーム処理(df_master.iloc[:, :10] と固定の英語列名)が実態に合わなくなります。
Foundation Model API に届くか確かめる
最後に、AI コメント生成に使う Databricks Foundation Model API の疎通を確認します。
これはワークスペース内部の API なので、外部トークンは要りません。
import mlflow.deployments
# 本番(03_scoring・エージェント)で実際に使う 70B の疎通を確認する。
FMAPI_MODEL = "databricks-meta-llama-3-3-70b-instruct"
try:
client = mlflow.deployments.get_deploy_client("databricks")
response = client.predict(
endpoint=FMAPI_MODEL,
inputs={"messages": [{"role": "user", "content": "Hello! Reply in one word."}], "max_tokens": 20}
)
reply = response["choices"][0]["message"]["content"]
print(f"✅ Databricks Foundation Model API 疎通OK")
print(f" モデル: {FMAPI_MODEL}")
print(f" レスポンス: {reply}")
except Exception as e:
print(f"❌ Foundation Model API エラー: {e}")
print("→ モデル名を確認するか、別のエンドポイントを試してください")
外部の AI サービス(HuggingFace の Inference API など)は Free Edition のアウトバウンド制限で
到達できないため、本記事は一貫してワークスペース内部の Foundation Model API を使います。
トークンの発行も Secrets の管理も要らないのは、この制約が結果的にもたらした利点です。
ここで確かめるのは、本番と同じモデル名です。 このワークスペースで実際に使えるチャット系の
エンドポイントは、AI コメント生成とエージェント本体に使う
databricks-meta-llama-3-3-70b-instruct、軽量な databricks-meta-llama-3-1-8b-instruct、
そして LLM ジャッジに使う databricks-llama-4-maverick などです。8B は日本語の品質が
足りないため、本記事では 70B を使います。
なお、エンドポイントの一覧に名前が出ていても、実際には呼べないモデルがあります
(第6章で扱います)。一覧に載っていることと呼べることは別なので、
使う予定のモデルは、こうして実際に1回叩いて確かめておくのが確実です。
まとめて完了チェックする
章の最後に、ここまでの5点をまとめて判定します。
print("=" * 50)
print("セットアップ 完了チェック")
print("=" * 50)
checks = {
"ライブラリインストール": None,
"Unity Catalog スキーマ (bronze/silver/gold)": None,
"yfinance 疎通": None,
"JPX Excel 取得": None,
"Databricks Foundation Model API 疎通": None,
}
try:
import yfinance, pandas, pandas_ta, requests # この4つが import できて初めてインストール成功
checks["ライブラリインストール"] = True
except Exception:
checks["ライブラリインストール"] = False
try:
schemas = [r.databaseName for r in spark.sql("SHOW SCHEMAS IN stock_analysis").collect()]
checks["Unity Catalog スキーマ (bronze/silver/gold)"] = all(s in schemas for s in ["bronze", "silver", "gold"])
except Exception:
checks["Unity Catalog スキーマ (bronze/silver/gold)"] = False
try:
h = yf.Ticker("7203.T").history(period="1d")
checks["yfinance 疎通"] = not h.empty
except Exception:
checks["yfinance 疎通"] = False
try:
import io
r = requests.get(JPX_URL, timeout=15)
pd.read_excel(io.BytesIO(r.content)) # ダウンロードだけでなく「読めるか」まで見る
checks["JPX Excel 取得"] = (r.status_code == 200)
except Exception:
checks["JPX Excel 取得"] = False
try:
import mlflow.deployments
c = mlflow.deployments.get_deploy_client("databricks")
res = c.predict(
endpoint="databricks-meta-llama-3-3-70b-instruct",
inputs={"messages": [{"role": "user", "content": "Hi"}], "max_tokens": 5}
)
checks["Databricks Foundation Model API 疎通"] = True
except Exception:
checks["Databricks Foundation Model API 疎通"] = False
for item, result in checks.items():
status = "✅" if result is True else ("❌" if result is False else "⚠️ 未確認")
print(f"{status} {item}")
print("\n次のステップ: 01_collect_data.py を実行してください")
各項目を True / False / None の3値で持っています。None(⚠️ 未確認)は
「試したが判定できなかった」ではなく「まだ判定していない」を表します。このセルは
全項目をその場で try / except に通すので、実際には ⚠️ 未確認 は出ません。3値にしてあるのは、
あとで判定項目を増やしたときに、書き忘れた項目が False(失敗)に紛れないようにするためです。
判定を1つずつ try / except で囲んでいるのも意図的です。yfinance が落ちても JPX と
Foundation Model API の判定は続き、どこまで通っていて、どこが駄目なのかが1回の実行で
すべて出ます。最初の失敗で止まってしまうと、原因を1つ直すたびに再実行することになります。
5項目すべてに ✅ が並べば、第2部を進める準備は完了です。
まとめ
- 第2部の器は
stock_analysisカタログとbronze/silver/goldの3スキーマ。
テーブルは自動で作られるが、カタログとスキーマは自分で作る必要がある -
CREATE CATALOG IF NOT EXISTS/CREATE SCHEMA IF NOT EXISTSなので何度実行しても安全 - 実装に入る前に、外部依存(yfinance・JPX・Foundation Model API)に1回ずつ届くか確かめる。
原因の切り分けは、実装の最中より前にやるほうが安い - 疎通確認は「例外が飛ぶ」だけでなく「空で返る」失敗も見る
- 完了チェックは
True/False/Noneの3値で持ち、未確認と失敗を区別する
次章(Bronze)へ: 器が用意できたので、いよいよデータを入れていきます。次章では
JPX の銘柄一覧と yfinance の株価・財務データを、加工せずそのまま
stock_analysis.bronze.*に貯めます。この章で疎通を確かめた3つの経路が、そのまま
本番のデータ取得経路になります。
第1章 Bronze層 — 生データ取り込み
第1部で学んだ Delta Lake とメダリオンアーキテクチャを、いよいよ実際の株式データに
適用していきます。この章で作るのは、メダリオンの入口である Bronze 層 です。
Bronze 層の役割は「外部から取ってきた生データを、できるだけ加工せずそのまま貯める」ことに
尽きます。ここで無理に整形や型付けをすると、後で「元データはどうだったか」を
確かめられなくなります。特徴量の計算は次章(Silver)に回し、Bronze では
取得したものを取得したまま残すことを基本にし、銘柄マスタと財務指標には取得時刻
retrieved_at も添えます。列名の統一や
取得対象の絞り込み、財務指標の数値化など、必要最小限の整形だけは Bronze で行います
(どこで何をしているかは各節で示します)。
この章で用意するデータソースは3つです。
| テーブル | データ源 | 中身 |
|---|---|---|
stock_analysis.bronze.stock_master |
JPX 公開の銘柄一覧(Excel) | 東証プライム/スタンダード/グロースの内国株 |
stock_analysis.bronze.stock_prices |
yfinance | 各銘柄の株価履歴 3年分 |
stock_analysis.bronze.stock_financials |
yfinance | PER・ROE・成長率などの財務指標 |
この章のノートブック:
notebooks/01_collect_data.py(本章のコードは全文このノートブックと一致します)
カタログ規約: 書き込み先はすべてstock_analysis.bronze.*(3部構成の完全修飾名で書く)
依存ライブラリの導入
先頭のセルで、外部データ取得に必要なライブラリを入れます。
%pip install yfinance pandas-ta requests openpyxl -q
| ライブラリ | 用途 |
|---|---|
yfinance |
株価・財務データの取得 |
requests |
JPX の Excel ファイル取得 |
openpyxl |
Excel ブック(.xlsx)の読み込み |
pandas-ta |
テクニカル指標計算(次章 Silver で使用。次章のノートブックでもインストール) |
pandas-ta はこの章では使いません(次章のノートブックでも、あらためてインストールします)。
それでもここで入れておくのは、pandas-ta が入る環境(Python 3.12 系)になっているかを、
最初の章で確かめておくためです。このライブラリが Free Edition で思わぬ落とし穴になるため、章末にコラムとして原因と対処を
まとめました。インストールで失敗したらまずそちらを参照してください。
インストール後、この章で使うモジュールを読み込みます。
import requests
import pandas as pd
import yfinance as yf
import time
import io
from pyspark.sql import SparkSession
from pyspark.sql.types import StructType, StructField, StringType, DoubleType, LongType, TimestampType, DateType
from datetime import datetime
spark = SparkSession.builder.getOrCreate()
print('Libraries loaded OK')
銘柄マスタ — JPX の Excel ファイルを取り込む
最初に「どの銘柄を対象にするか」を決める一覧を用意します。日本取引所グループ(JPX)が
公開している上場銘柄一覧の Excel ファイルを直接ダウンロードして読み込みます。ネットワークや
公開ファイルの変更で失敗しうるので、取得は try/except で囲み、失敗を明示的なエラーに
変換します。
JPX_URL = 'https://www.jpx.co.jp/markets/statistics-equities/misc/tvdivq0000001vg2-att/data_j.xlsx'
try:
resp = requests.get(JPX_URL, timeout=30)
resp.raise_for_status()
df_master = pd.read_excel(io.BytesIO(resp.content), engine='openpyxl')
print(f'JPX Excel取得成功: {df_master.shape}')
except Exception as e:
raise RuntimeError(f'JPX Excel取得失敗: {e}')
ダウンロードしたバイト列を io.BytesIO で包み、いったんファイルに落とさず
そのまま pandas に渡しています。この Excel ファイルには日付・コード・銘柄名・市場区分・業種など
10列が入っているので、扱いやすい英語の列名に付け替えます。列数が想定と違えば、
黙って進めず ValueError で止めます(フォーマット変更の早期検知)。
# 実際の列名を確認して10列にリネーム
# 実際の列: ['日付','コード','銘柄名','市場・商品区分','33業種コード','33業種区分','17業種コード','17業種区分','規模コード','規模区分']
n_cols = len(df_master.columns)
if n_cols >= 10:
df_master = df_master.iloc[:, :10]
df_master.columns = [
'date', 'code', 'name', 'market_segment',
'industry_33_code', 'industry_33',
'industry_17_code', 'industry_17',
'scale_index_code', 'scale_index'
]
else:
raise ValueError(f'想定外のカラム数: {n_cols}(期待値: 10)')
対象を絞る — 内国株の3市場だけ
JPX の一覧には ETF や REIT、外国株なども含まれます。今回のスクリーニング対象は
東証の**内国株式(プライム・スタンダード・グロース)**なので、市場区分の文字列で
フィルタします。
# 内国株式のプライム・スタンダード・グロースのみ抽出
# 実際の値例: 'プライム(内国株式)', 'スタンダード(内国株式)', 'グロース(内国株式)'
df_master = df_master[
df_master['market_segment'].astype(str).str.contains('内国株式', na=False)
& df_master['market_segment'].astype(str).str.contains('プライム|スタンダード|グロース', na=False)
].copy()
Bronze らしさ — 全列を文字列のまま残す
ここが Bronze 層の思想がよく表れる部分です。数値に見える列(業種コードなど)も含め、
いったんすべて文字列に統一し、そのうえで取得時刻 retrieved_at を付けて保存します。
0件なら異常なので ValueError で止めます。
# 全カラムを文字列に統一してから retrieved_at を追加(型変換エラー回避)
for col in df_master.columns:
df_master[col] = df_master[col].astype(str)
df_master['retrieved_at'] = pd.Timestamp.now()
print(f'対象銘柄数: {len(df_master)}')
print(df_master['market_segment'].value_counts())
if len(df_master) == 0:
raise ValueError('銘柄マスタが0件')
型を早々に付けてしまうと、想定外の値(全角スペース、欠損、注記記号など)が混じった
ときに取り込み自体が失敗します。Bronze では「まず入れる」を優先し、型付けは
後段で必要に応じて行う——これが層を分ける実利です。retrieved_at を刻んでおけば、後から
「このデータはいつ時点のものか」を追えます。財務指標のテーブルにも同じ列を付けます
(株価のテーブルは date 列が時点を表すので付けません)。
保存は Delta テーブルへの上書き(overwrite)です。銘柄マスタは毎回まるごと
取り直すので、冪等な上書きが素直です。
spark_df = spark.createDataFrame(df_master)
spark_df.write.format('delta').mode('overwrite').option('overwriteSchema', 'true') \
.saveAsTable('stock_analysis.bronze.stock_master')
print(f'\n✅ stock_analysis.bronze.stock_master 保存完了: {len(df_master)}件')
実行すると、対象銘柄数と市場区分ごとの件数が表示されます。件数は上場の増減で変わりますが、
次のような値になれば取り込めています(2026年10月時点の一例)。
- 対象銘柄数: 3,702
- 内訳: スタンダード(内国株式)1,559 / プライム(内国株式)1,550 / グロース(内国株式)593
株価 — yfinance から3年分を取る
マスタで確定した銘柄について、株価履歴を取得します。yfinance は米国式の
ティッカー表記を使うので、証券コードに .T(東証)を付けて渡します。
PRICE_PERIOD = '3y'
BATCH_SIZE = 500
SLEEP_SEC = 1
tickers = [f"{code}.T" for code in df_master['code'].tolist()]
print(f'取得対象: {len(tickers)}銘柄')
外部 API を相手にする作法 — バッチ・スリープ・失敗の許容
数千銘柄を一気に取りにいくと、レート制限(短時間にリクエストを送りすぎると相手に断られる仕組み)や一時的なエラーに必ずぶつかります。
そこで 500銘柄ずつのバッチに区切り、バッチの合間に短いスリープを挟みます。
そして、個々の銘柄で失敗しても全体を止めない——失敗は failed_price に記録して
先へ進めます。
all_prices = []
failed_price = []
batches = [tickers[i:i+BATCH_SIZE] for i in range(0, len(tickers), BATCH_SIZE)]
for batch_idx, batch in enumerate(batches):
print(f'バッチ {batch_idx+1}/{len(batches)} 処理中...')
for ticker_str in batch:
try:
ticker_obj = yf.Ticker(ticker_str)
hist = ticker_obj.history(period=PRICE_PERIOD)
if hist.empty:
failed_price.append(ticker_str)
continue
hist = hist.reset_index()
hist['ticker'] = ticker_str
hist['code'] = ticker_str.replace('.T', '')
# カラム名を小文字・スペースをアンダースコアに
hist.columns = [c.lower().replace(' ', '_') for c in hist.columns]
# Date列をdatetimeに統一
if 'date' in hist.columns:
hist['date'] = pd.to_datetime(hist['date']).dt.tz_localize(None)
all_prices.append(hist)
except Exception as e:
failed_price.append(ticker_str)
time.sleep(SLEEP_SEC)
print(f'\n取得成功: {len(all_prices)}銘柄 / 失敗: {len(failed_price)}銘柄')
if failed_price:
print(f'失敗銘柄(先頭20件): {failed_price[:20]}')
外部データを扱う処理は「一部が欠けても動き続ける」設計が要です。上場廃止直後の銘柄や
新規上場で履歴が短い銘柄など、取れないものは出ます。取得に成功した銘柄数は、週によって
大きく揺れることもあります(本記事の週次実行の記録では、2,553 銘柄の週も 3,702 銘柄の週もありました)。取れなかった銘柄を failed_price に
ためて後で件数を確認できるようにしておけば、全体の健全性を把握しつつ処理を完走できます。
取得できた分を1枚の DataFrame に結合し、そのまま Bronze に保存します。ここでも
値の計算や補正はせず、yfinance が返した列(open / high / low / close / volume など)を
小文字化し、日付のタイムゾーンを外し、銘柄を示す ticker・code 列を足しただけで落とし込みます。
df_prices = pd.concat(all_prices, ignore_index=True)
print(f'株価レコード数: {len(df_prices):,}')
spark_df = spark.createDataFrame(df_prices)
spark_df.write.format('delta').mode('overwrite').option('overwriteSchema', 'true') \
.saveAsTable('stock_analysis.bronze.stock_prices')
print(f'\n✅ stock_analysis.bronze.stock_prices 保存完了: {len(df_prices):,}レコード')
同じ時点の一例では、取得対象 3,702 銘柄に対して株価レコードは約264万件(2,638,705件)でした。
銘柄ごとに約3年分の営業日が入るため、1銘柄あたり700件前後になります。
財務指標 — リトライで取りこぼしを減らす
最後に、スコアリングの材料になる財務指標(PER・PBR・ROE・ROA・売上成長率・
粗利益率・フリーキャッシュフローなど)を取得します。yfinance の .info は株価履歴より
不安定で失敗しやすいため、指数バックオフ付きのリトライ
(失敗するたびに待ち時間を 1 秒、2 秒と倍に伸ばして再試行する方式)を関数に閉じ込めます。
def _to_float(v):
try:
f = float(v)
return f if f != 0.0 else None
except (TypeError, ValueError):
return None
def fetch_financial_info(ticker_str: str, max_retries: int = 3) -> dict | None:
"""リトライロジック付きで yfinance から財務情報を取得する"""
for attempt in range(max_retries):
try:
info = yf.Ticker(ticker_str).info
if not info or info.get('quoteType') not in ('EQUITY', None):
return None
return {
'code': ticker_str.replace('.T', ''),
'ticker': ticker_str,
'market_cap': _to_float(info.get('marketCap')),
'per': _to_float(info.get('trailingPE')),
'pbr': _to_float(info.get('priceToBook')),
'roe': _to_float(info.get('returnOnEquity')),
'roa': _to_float(info.get('returnOnAssets')),
'revenue_growth': _to_float(info.get('revenueGrowth')),
'earnings_growth': _to_float(info.get('earningsGrowth')),
'debt_to_equity': _to_float(info.get('debtToEquity')),
'current_ratio': _to_float(info.get('currentRatio')),
'dividend_yield': _to_float(info.get('dividendYield')),
'operating_margin': _to_float(info.get('operatingMargins')),
'profit_margin': _to_float(info.get('profitMargins')),
'gross_margin': _to_float(info.get('grossMargins')),
'free_cashflow': _to_float(info.get('freeCashflow')),
'total_revenue': _to_float(info.get('totalRevenue')),
'enterprise_to_ebitda': _to_float(info.get('enterpriseToEbitda')),
'retrieved_at': pd.Timestamp.now(),
}
except Exception:
if attempt < max_retries - 1:
time.sleep(2 ** attempt)
else:
return None
return None
この関数は、.info に入っている多数の項目のうち、スコアリングで使う16項目だけを選び、
列名も付け替えて(例: trailingPE → per)返します。
小さなヘルパ _to_float にも Bronze の判断が現れています。数値化できない値と
0.0 を None(欠損)に寄せています。「0 は実測値ではなく、データが無い印かもしれない」
と疑い、後段の集計で 0 を実数として誤って扱う事故を避けるためです。
ただし、これにはトレードオフがあります。本当に 0 である値(無配当の dividend_yield、
無借金の debt_to_equity など)も欠損になってしまいます。実際に stock_financials では
3,702 件中、dividend_yield の 617 件、debt_to_equity の 605 件が欠損です
(2026年10月時点の一例。欠損の中には、本当は 0 だった値も含まれうる)。
0 と欠損を区別したくなったら、この関数の判断を見直してください。
取得は株価と同じくバッチで回します。
# ── 初回バッチ取得 ──────────────────────────────────────────────────
BATCH_SIZE_FIN = 500
SLEEP_SEC_FIN = 1
financial_records = []
failed_fin = []
batches_fin = [tickers[i:i+BATCH_SIZE_FIN] for i in range(0, len(tickers), BATCH_SIZE_FIN)]
for batch_idx, batch in enumerate(batches_fin):
print(f'財務バッチ {batch_idx+1}/{len(batches_fin)} 処理中...')
for ticker_str in batch:
result = fetch_financial_info(ticker_str)
if result is not None:
financial_records.append(result)
else:
failed_fin.append(ticker_str)
time.sleep(SLEEP_SEC_FIN)
print(f'\n初回取得: 成功 {len(financial_records)}件 / 失敗 {len(failed_fin)}件')
そのうえで、初回に失敗した銘柄だけをもう一度、小さめのバッチ・長めのスリープで
取り直します。一時的なエラーで落ちた分の多くは、この2周目で拾えます。
# ── 失敗分リトライ ──────────────────────────────────────────────────
if failed_fin:
print(f'\n失敗分リトライ中... ({len(failed_fin)}件)')
RETRY_BATCH_SIZE = 200
RETRY_SLEEP = 2
still_failed = []
retry_batches = [failed_fin[i:i+RETRY_BATCH_SIZE] for i in range(0, len(failed_fin), RETRY_BATCH_SIZE)]
for batch_idx, batch in enumerate(retry_batches):
print(f' リトライバッチ {batch_idx+1}/{len(retry_batches)} ({len(batch)}銘柄)...')
for ticker_str in batch:
result = fetch_financial_info(ticker_str, max_retries=3)
if result is not None:
financial_records.append(result)
else:
still_failed.append(ticker_str)
time.sleep(RETRY_SLEEP)
recovered = len(failed_fin) - len(still_failed)
print(f'リトライ後: 追加取得 {recovered}件 / 最終失敗 {len(still_failed)}件')
if still_failed:
print(f'最終失敗銘柄(先頭20件): {[t.replace(".T","") for t in still_failed[:20]]}')
集まった財務レコードを Bronze に保存します。
df_financials = pd.DataFrame(financial_records)
print(f'\n財務データ合計: {len(df_financials)}銘柄')
spark_df = spark.createDataFrame(df_financials)
spark_df.write.format('delta').mode('overwrite').option('overwriteSchema', 'true') \
.saveAsTable('stock_analysis.bronze.stock_financials')
print(f'\n✅ stock_analysis.bronze.stock_financials 保存完了: {len(df_financials)}件')
取り込み結果を検証する
3テーブルが揃ったら、件数を確認し、下限を assert で保証します。データ収集は
外部要因で結果が揺れるので、「最低限これだけは取れているはず」という不変条件を
コードに書いておくと、異常な回(マスタが数十件しか取れていない等)でパイプラインを
止められます。ただし止められるのは、マスタが極端に少ない、株価や財務が空、といった
最悪のケースだけです。株価・財務が一部欠ける程度では止まらないので、取得に成功した
銘柄数は後述の run_logs で確認します。
print('=== 完了確認 ===')
master_cnt = spark.table('stock_analysis.bronze.stock_master').count()
prices_cnt = spark.table('stock_analysis.bronze.stock_prices').count()
fin_cnt = spark.table('stock_analysis.bronze.stock_financials').count()
print(f'stock_master : {master_cnt:>8,} 件')
print(f'stock_prices : {prices_cnt:>8,} 件')
print(f'stock_financials: {fin_cnt:>8,} 件')
assert master_cnt >= 1000, f'stock_master が1000件未満: {master_cnt}'
assert prices_cnt > 0, 'stock_prices が空'
assert fin_cnt > 0, 'stock_financials が空'
print('\n✅ 全チェック通過')
すべて通ると、次のように表示されます(件数は一例)。
=== 完了確認 ===
stock_master : 3,702 件
stock_prices : 2,638,705 件
stock_financials: 3,702 件
✅ 全チェック通過
実行ログを Gold に残す
最後に、この回の実行結果を stock_analysis.gold.run_logs に**追記(append)**します。
いつ・どのフェーズが・何件で・成功したかを1行ずつためていくと、定期実行を始めた後に
「先週から取得できる銘柄が減っていないか」といった運用上の異変に気づけます。ログの
スキーマは明示的に指定します(None を含む行で型推論に失敗するのを避けるため)。
# 実行ログ記録(明示的スキーマで None 型推論エラーを回避)
from pyspark.sql.types import StructType, StructField, StringType, IntegerType, DoubleType
log_schema = StructType([
StructField('run_date', StringType(), True),
StructField('phase', StringType(), True),
StructField('record_count', IntegerType(), True), # そのフェーズの件数(collect: 収集成功銘柄数)
StructField('top_score', DoubleType(), True),
StructField('status', StringType(), True),
])
log_record = [(
pd.Timestamp.now().isoformat(),
'collect',
int(len(all_prices)),
None,
'success',
)]
spark.createDataFrame(log_record, schema=log_schema) \
.write.format('delta').mode('append') \
.option('mergeSchema', 'true') \
.saveAsTable('stock_analysis.gold.run_logs')
print('✅ run_logs 記録完了')
Free Edition の注意: Databricks Free Edition はアウトバウンド通信に制限があり、
yfinance や JPX の Excel ファイルに到達できないことがあります。本記事の実行環境では
どちらにも到達でき、この章のコードはそのまま動きました。届かない場合は、ローカル環境で
データを収集し、Unity Catalog の Volumes 経由でアップロードしてから同じ Bronze
テーブルに書き込む、という回避策があります(具体的な手順は本記事の範囲外です)。
取り込み以降の処理(Silver / Gold)はデータの入手経路によらず同じです。
まとめ
- Bronze 層は「生データをそのまま貯める」。特徴量の計算は次章 Silver に回す
- 銘柄マスタは JPX の Excel ファイルを取り込み、内国株の3市場に絞る。全列を文字列のまま残して取り込み失敗を避ける
- 株価・財務は yfinance から取得。バッチ・スリープ・失敗の許容・リトライで、外部 API の不安定さを吸収する
- 銘柄マスタと財務指標には
retrieved_atで取得時刻を刻み、assertで下限を保証し、run_logsに実行結果を追記する
次章(Silver)へ: Bronze にためた生データから、
テクニカル指標を計算したstock_analysis.silver.stock_featuresに磨き上げます。
そこで初めてpandas-taを本格的に使います。その前に、pandas-taが Free Edition で
引き起こす落とし穴を次のコラムで押さえておきましょう。
コラム:サーバーレスの Python バージョンと pandas-ta(Free Edition の落とし穴)
何が起きたか。 ある日、それまで動いていたセットアップ用ノートブックをジョブ実行したところ、
いちばん最初の依存インストールセル
%pip install yfinance pandas-ta requests openpyxl
で失敗し、ジョブが INTERNAL_ERROR / FAILED になりました。エラーは次のとおりです。
ERROR: Ignored the following versions that require a different python version:
0.4.67b0 Requires-Python >=3.12; 0.4.71b0 Requires-Python >=3.12
ERROR: Could not find a version that satisfies the requirement pandas-ta (from versions: none)
ERROR: No matching distribution found for pandas-ta
原因の切り分け。 パッケージを1つずつ入れて確かめると、yfinance / requests / openpyxl は
問題なく入り、pandas-ta だけが失敗していました。サーバーレスの既定環境は当時 Python 3.11.10 で、
一方このとき PyPI に公開されていた pandas-ta は 0.4.67b0 / 0.4.71b0 の2つだけ。どちらも
Requires-Python >=3.12 のため、**Python 3.11 では「入れられる版が1つも存在しない」**状態になっていました
(旧 0.3.x 系も解決できませんでした)。ライブラリ側が対応 Python の下限を上げると、こちらは何も変えていなくても
ある日突然インストールが壊れる——依存の外部要因で足元が動く典型例です。
対処。 旧版へピン留め(pandas-ta==0.3.14b0 等)で逃げる手もありますが、将来また壊れる技術的負債になります。
本記事ではサーバーレスの「環境バージョン(Environment version)」を上げて Python 3.12 系にする方針を採ります。
- ノートブック(対話実行): 右側の「環境」パネルで Environment version を 3 以上(本記事のノートブックは 5)に上げて Apply。
ここで Python 自体が切り替わります。 -
ジョブ実行(自動実行・GUI): notebook タスクの環境設定はノートブックに紐づくので、ジョブの GUI で
タスクの「環境とライブラリ」を「ノートブック環境」に設定すると、ノートブック側で選んだ環境
(=Python 3.12)がジョブ実行にも使われます。ここが既定のまま(ジョブ独自のサーバーレス環境)だと、
ノートブックを 3.12 にしてもジョブは旧環境で走り、同じインストール失敗が再発するので注意してください。
※ Jobs API(jobs submit等)から notebook タスクを投げる場合は、ノートブックの環境がそのまま
引き継がれることを確認済みです(3.12 化後に再実行して成功)。
教訓。
-
「昨日まで動いていた」は当てにならない。 クラウドの共有環境では、ライブラリの対応 Python が上がるだけで
%pip installが失敗しうる。エラーメッセージ(Requires-Python)を読めば原因はすぐ分かる。 - Python バージョンはコードではなく環境バージョンで決まる。 直したいときは環境バージョンを選び直す。
- **落とし穴のデバッグは「1つずつ入れて切り分ける」**のが速い。まとめて入れると、どれが犯人か分からない。
- ⚠️ ノートブックを外部から
databricks workspace importで上書きすると、設定した環境が消えることがある。
環境を設定したノートブックは、安易に再インポートしない(する場合は環境の再設定を前提に)。
第2章 Silver層 — 特徴量計算
Bronze 層には、外部から取ってきた生データがそのまま貯まっています。この章では、その
生データを磨いて Silver 層 を作ります。Silver 層の役割は、Bronze の「取れたまま」を
分析に使える形に整えることです。一般にはクレンジング(欠損や異常値の処理)や型付けも
この層で行いますが、本記事の株価データは Bronze の時点で型が付いているため、この章のコードが
するのは特徴量の計算が中心で、欠損や異常値を取り除くクレンジングは行いません。
具体的には、株価の時系列から テクニカル指標
(移動平均・RSI・MACD・ボリンジャーバンド・出来高移動平均・モメンタム)を計算し、
stock_analysis.silver.stock_features に落とします。
この章のノートブック:
notebooks/02_screening.py(本章は前半のテクニカル指標計算部分)
カタログ規約: 入力stock_analysis.bronze.*/ 書き込みstock_analysis.silver.stock_featuresこのノートブックの後半(スクリーニングによる
gold.screening_results生成)は、Gold 層の
テーブルを作る処理なので、第3章「Gold層 — スクリーニングとスコアリング」でスコアリングとあわせて扱います。
テクニカル指標に pandas-ta を使う
テクニカル指標の計算には pandas-ta ライブラリを使います。名前のとおり pandas の
DataFrame を入力に取る、時系列分析向けのライブラリです。
%pip install pandas-ta -q
注意:
pandas-taは Python 3.12 以上が必須で、サーバーレスの既定環境(Python 3.11)
ではインストールに失敗します。原因と対処(環境バージョンを上げる)は、第2部 第1章(Bronze 層)の章末コラム
「サーバーレスの Python バージョンとpandas-ta」を参照してください。
インストール後、モジュールを読み込みます。
import pandas as pd
import pandas_ta as ta
from pyspark.sql import SparkSession
spark = SparkSession.builder.getOrCreate()
print('Libraries loaded OK')
Bronze から株価を読み、pandas に移す
Bronze の株価履歴を読み込みます。pandas-ta は pandas のライブラリなので、Spark の
テーブルを toPandas() で pandas DataFrame に変換してから計算します。
print('bronze.stock_prices 読み込み中...')
df_prices = spark.table('stock_analysis.bronze.stock_prices').toPandas()
print(f'株価レコード数: {len(df_prices):,}')
df_prices['date'] = pd.to_datetime(df_prices['date'])
df_prices = df_prices.sort_values(['code', 'date'])
読み込みが済むと、株価のレコード数が表示されます(2026年10月時点の一例)。
bronze.stock_prices 読み込み中...
株価レコード数: 2,638,705
ここでは日付の扱いをそろえています。Bronze の時点で date は日付型(timestamp)なので、
pd.to_datetime は念のための型そろえで、値は変わりません。テクニカル指標は「時系列順」に
意味があるので、銘柄コード → 日付 の順に並べ替えます。ただし実際の計算は、次の節のループ内で
銘柄ごとに日付順へ並べ直してから行います。
toPandas()は全データをドライバ(単一ノード)のメモリに集めます。東証全銘柄×3年分でも
Free Edition のサーバーレスで扱える規模です。本記事のコードは銘柄を分割せず、一括で処理します
(500銘柄ごとに出る表示は、進捗を知らせるためのものです)。クォータを超える場合は、
銘柄コードを数百件ずつに分けて読み込み・計算する形に、自分で書き換えてください。
銘柄ごとに指標を計算する
ここがこの章の核です。テクニカル指標は 1銘柄の時系列 に対して計算するものなので、
groupby('code') で銘柄ごとに切り出し、それぞれ日付順に並べてから指標を求めます。
銘柄をまたいで一括計算してしまうと、ある銘柄の終値が別の銘柄の移動平均に混入してしまう
ため、この「銘柄ごと」が欠かせません。
result_list = []
total = df_prices['code'].nunique()
for i, (code, group) in enumerate(df_prices.groupby('code')):
g = group.sort_values('date').copy()
g['ma25'] = ta.sma(g['close'], length=25)
g['ma75'] = ta.sma(g['close'], length=75)
g['ma200'] = ta.sma(g['close'], length=200)
g['rsi14'] = ta.rsi(g['close'], length=14)
macd_df = ta.macd(g['close'])
if macd_df is not None:
macd_col = next((c for c in macd_df.columns if c.startswith('MACD_')), None)
macdh_col = next((c for c in macd_df.columns if c.startswith('MACDh_')), None)
g['macd'] = macd_df[macd_col] if macd_col else None
g['macd_hist'] = macd_df[macdh_col] if macdh_col else None
else:
g['macd'] = None
g['macd_hist'] = None
bb = ta.bbands(g['close'], length=20)
if bb is not None:
bbu_col = next((c for c in bb.columns if c.startswith('BBU')), None)
bbl_col = next((c for c in bb.columns if c.startswith('BBL')), None)
g['bb_upper'] = bb[bbu_col] if bbu_col else None
g['bb_lower'] = bb[bbl_col] if bbl_col else None
else:
g['bb_upper'] = None
g['bb_lower'] = None
g['vol_ma20'] = g['volume'].rolling(20).mean()
g['price_mom_12m'] = g['close'].pct_change(252)
result_list.append(g)
if (i + 1) % 500 == 0:
print(f' {i+1}/{total} 銘柄処理済み')
df_features = pd.concat(result_list, ignore_index=True)
print(f'テクニカル指標計算完了: {len(df_features):,} レコード')
500銘柄ごとに進捗が表示され、最後に全レコード数が出ます。計算してもレコードは増減しないので、
読み込み時と同じ件数になります(一例)。
500/3702 銘柄処理済み
1000/3702 銘柄処理済み
……
3500/3702 銘柄処理済み
テクニカル指標計算完了: 2,638,705 レコード
計算している指標は次のとおりです。
| 列 | 指標 | 意味 |
|---|---|---|
ma25 / ma75 / ma200
|
単純移動平均(25/75/200日) | 短期・中期・長期のトレンド |
rsi14 |
RSI(14日) | 買われ過ぎ/売られ過ぎの目安 |
macd / macd_hist
|
MACD とヒストグラム | トレンド転換の勢い |
bb_upper / bb_lower
|
ボリンジャーバンド(20日) | 価格のばらつきの上下限 |
vol_ma20 |
出来高の20日移動平均 | 流動性の目安 |
price_mom_12m |
12ヶ月モメンタム | 過去約1年(252営業日)の騰落率 |
price_mom_12m の pct_change(252) は、252営業日 ≒ 1年 を使った変化率です。
252 は、米国市場の年間営業日数に由来する、1年の近似としてよく使われる値です。東証の営業日数は
年におよそ244日(本記事のデータでは3年で732日)なので、厳密には約1.03年前との比較になりますが、
本記事では目安として 252 を使います。
なお、この表の指標のうち、後の章のスクリーニングとスコアリングで実際に使うのは、移動平均・RSI・
出来高移動平均・12ヶ月モメンタムです。MACD とボリンジャーバンドは Silver に保存しますが、
本記事のスクリーニング・スコアリングでは使いません(MACD をスコアに入れない理由は第3章で述べます)。
ライブラリの戻り値に振り回されない工夫
ta.macd() や ta.bbands() は、複数列を持つ DataFrame を返します。しかもその列名には
MACD_12_26_9 のようにパラメータが埋め込まれ、ライブラリのバージョンによって微妙に
変わることがあります。そこで列名を決め打ちせず、startswith('MACD_') のように
接頭辞で探して取り出し、見つからなければ None を入れています。外部ライブラリの
出力形式に依存し過ぎない、こうした防御が、後々のバージョン差で壊れないコツです。
最新日スナップショットを Silver に保存する
指標は全期間について計算しましたが、Silver に保存するのは 最新日の1スナップショット
だけにします。スクリーニングやスコアリングは「いま」の状態に対して行うものなので、
Silver は最新日の状態だけを持てば十分です。全期間の履歴は Bronze に残っているので、
必要になればそこから再計算できます。
# 最新日スナップショットのみ silver に保存
latest_date = df_features['date'].max()
df_silver = df_features[df_features['date'] == latest_date].copy()
print(f'最新日付: {latest_date.date()} 対象銘柄数: {len(df_silver)}')
spark.createDataFrame(df_silver).write.format('delta').mode('overwrite') \
.option('overwriteSchema', 'true') \
.saveAsTable('stock_analysis.silver.stock_features')
print(f'\n✅ stock_analysis.silver.stock_features 保存完了: {len(df_silver)} 件')
保存が成功すると、最新日と保存した銘柄数が表示されます(一例)。
最新日付: 2026-10-09 対象銘柄数: 3691
✅ stock_analysis.silver.stock_features 保存完了: 3691 件
保存された stock_features には、Bronze の株価テーブルの10列(date / open / high / low /
close / volume / dividends / stock_splits / ticker / code)に、上の表の指標10列を足した
計20列が入ります。
mode('overwrite') なので、毎回まるごと最新のスナップショットに置き換わります。
定期実行のたびに Silver が「その週の最新状態」に更新される、という運用です。
ここで残るのは、全銘柄の中で最も新しい日付(最新日)に記録がある銘柄の、その日の行です。
売買停止中などで最新日に記録が無い銘柄は、Silver に含まれません。実測の一例では、
Bronze の 3,702 銘柄に対して Silver は 3,691 銘柄でした。
まとめ
- Silver 層は Bronze の生データを整える層。この章ではテクニカル指標の計算を行う(クレンジングは行わない)
- テクニカル指標は
pandas-ta(Python 3.12 必須。第1章コラム参照)で計算する - 指標は 銘柄ごと・日付順 に計算する(
groupby('code')→sort_values('date'))。銘柄をまたぐと汚染される -
ta.macd()/ta.bbands()の列名は 接頭辞で探して取り出し、バージョン差に強くする - Silver に保存するのは 最新日スナップショット のみ(最新日に記録の無い銘柄は含まれない)。履歴は Bronze に残す
次章以降での回収: ここで計算した特徴量は、スクリーニング結果(
gold.*)の土台に
なります。次章(第3章)ではこの Silver の指標とファンダメンタル指標を突き合わせて
スクリーニングし、通過銘柄に100点満点のスコアを付けます。その後、パイプラインを
定期実行に載せ(第4章)、結果を Genie(第5章)やダッシュボード(第7章)で活用していきます。
第3章 Gold層 — スクリーニングとスコアリング
Bronze に生データを貯め(第1章)、Silver でテクニカル指標まで計算しました(第2章)。
ここまでで材料は揃っています。この章で作るのは、メダリオンの出口にあたる Gold 層 —
そのまま意思決定に使える形 のテーブルです。
Gold 層の作り方は2段階に分かれます。
-
絞る(スクリーニング): 全銘柄にファンダメンタル+テクニカルの条件を当て、
通過した銘柄だけをstock_analysis.gold.screening_resultsに落とす -
順位と言葉をつける(スコアリング): 通過銘柄に100点満点のルールベーススコアを付け、
上位30銘柄には Databricks Foundation Model API で長期保有目線のAIコメントを生成し、
同じテーブルを更新する
本記事のシステムでいちばん「効く」のがこの章です。3,700 銘柄を数十銘柄まで機械的に絞り込み、
順位づけし、人間が読める一言を添えるところまでを一気通貫でやります。
この章のノートブック:
notebooks/02_screening.py(後半 2-2 以降。前半のテクニカル指標計算は第2章)
とnotebooks/03_scoring.py(全体)
カタログ規約: 入力stock_analysis.silver.stock_features/stock_analysis.bronze.*、
書き込みstock_analysis.gold.screening_results・stock_analysis.gold.run_logs
前半: スクリーニング(02_screening.py 後半)
ノートブック 02_screening.py の後半、次の見出しセルから先が本章の前半です。
# MAGIC %md
# MAGIC ## 2-2. スクリーニング実行 → gold.screening_results
第2章から続けて実行するので、%pip install pandas-ta -q とインポート
(pandas / pandas_ta / SparkSession)は既に済んでいる前提です。
3つのテーブルを突き合わせる
スクリーニングには、テクニカル指標(Silver)と財務指標(Bronze)の両方が要ります。
さらに、結果を人間が読めるようにするため銘柄名・市場区分・業種(Bronze の銘柄マスタ)も
付けておきます。
df_tech = spark.table('stock_analysis.silver.stock_features').toPandas()
df_fund = spark.table('stock_analysis.bronze.stock_financials').toPandas()
df_master = spark.table('stock_analysis.bronze.stock_master').toPandas()
print(f'silver.stock_features : {len(df_tech):>6,} 件')
print(f'bronze.stock_financials: {len(df_fund):>6,} 件')
print(f'bronze.stock_master : {len(df_master):>6,} 件')
df = df_tech.merge(df_fund, on='code', how='inner')
df = df.merge(df_master[['code', 'name', 'market_segment', 'industry_33']], on='code', how='left')
print(f'\n結合後レコード数: {len(df):,}')
実行すると、3つのテーブルの件数と結合後の件数が表示されます(2026年10月時点の一例)。
Silver が3,691件なのは、第2章で見たとおり最新日に記録の無い銘柄が含まれないためです。
silver.stock_features : 3,691 件
bronze.stock_financials: 3,702 件
bronze.stock_master : 3,702 件
結合後レコード数: 3,691
結合の種類を使い分けている点に注目してください。
| 結合 | 種類 | 理由 |
|---|---|---|
| テクニカル × 財務 | inner |
両方の指標が揃っている銘柄だけを評価対象にする。財務が取れなかった銘柄はここで落ちる |
| (その結果)× 銘柄マスタ | left |
名前・業種は表示のための飾り。取れなくても評価は成立するので、行を落とさない |
「判断に使う列は inner、見せるための列は left」という切り分けです。これを逆にすると、
業種名が取れないというだけの理由で優良銘柄が候補から消えてしまいます。
なお market_segment と industry_33 は JPX の銘柄一覧に由来する列で、それぞれ
市場区分(プライム/スタンダード/グロース)と33業種区分を表します。
FCF マージンを補い、フィルタ条件を変数に出す
ここがスクリーニングの本体です。長いので、まずコード全体を示してから中身を追います。
# FCF マージン計算
def _fcf_margin(row):
rev = row.get('total_revenue')
fcf = row.get('free_cashflow')
if rev and rev > 0 and fcf is not None:
return fcf / rev
return None
df['fcf_margin'] = df.apply(_fcf_margin, axis=1)
# ── スクリーニングパラメータ(変更容易にするため変数化) ─────────────
PER_MAX = 25.0
PBR_MAX = 3.0
ROE_MIN = 0.08
REVENUE_GROWTH_MIN = 0.00
EARNINGS_GROWTH_MIN = -0.10
OPERATING_MARGIN_MIN = 0.05
GROSS_MARGIN_MIN = 0.20
FCF_MIN = 0.0 # free_cashflow > 0
CURRENT_RATIO_MIN = 1.0
DE_MAX = 100.0
RSI_MIN = 30.0
RSI_MAX = 75.0
VOL_MA20_MIN = 100_000
# ── ファンダメンタルフィルタ ──────────────────────────────────────────
f_fundamental = (
(df['per'].between(0, PER_MAX)) &
(df['pbr'].between(0, PBR_MAX)) &
(df['roe'] >= ROE_MIN) &
(df['revenue_growth'] >= REVENUE_GROWTH_MIN) &
(df['earnings_growth'].fillna(0) >= EARNINGS_GROWTH_MIN) &
(df['operating_margin'] >= OPERATING_MARGIN_MIN) &
(df['gross_margin'].fillna(0) >= GROSS_MARGIN_MIN) &
(df['free_cashflow'].fillna(0) > FCF_MIN) &
(df['current_ratio'].fillna(0) >= CURRENT_RATIO_MIN) &
(df['debt_to_equity'].fillna(999) <= DE_MAX)
)
# ── テクニカルフィルタ ────────────────────────────────────────────────
f_technical = (
(df['close'] > df['ma200']) &
(df['ma25'] > df['ma75']) &
(df['rsi14'].between(RSI_MIN, RSI_MAX)) &
(df['vol_ma20'].fillna(0) >= VOL_MA20_MIN)
)
# ── フィルタ別通過状況 ─────────────────────────────────────────────
n_total = len(df)
n_fundamental = f_fundamental.sum()
n_technical = f_technical.sum()
n_both = (f_fundamental & f_technical).sum()
print(f'全銘柄数 : {n_total:,}')
print(f'ファンダメンタル通過: {n_fundamental:,} ({n_fundamental/n_total*100:.1f}%)')
print(f'テクニカル通過 : {n_technical:,} ({n_technical/n_total*100:.1f}%)')
print(f'両方通過(最終候補) : {n_both:,} ({n_both/n_total*100:.1f}%)')
同じ時点の一例では、次のように絞り込まれました。
全銘柄数 : 3,691
ファンダメンタル通過: 132 (3.6%)
テクニカル通過 : 646 (17.5%)
両方通過(最終候補) : 32 (0.9%)
FCF マージンは自前で計算する
fcf_margin(フリーキャッシュフロー ÷ 売上高)は、データ源からそのままの形では取れないので
自分で作ります。売上高が 0 や欠損の銘柄で割り算が壊れないよう、rev > 0 と
fcf is not None を確かめてから割り、条件を満たさなければ None を返しています。
この列はスクリーニングでは使いませんが、後半のスコアリングで収益品質30点のうち10点を
担う重要な指標です。「稼いだ売上のうち、どれだけが実際に手元に残る現金か」を表すので、
長期保有の観点では利益率よりも信用できる場面があります。
なぜ閾値を変数に出すのか
PER_MAX = 25.0 のように、すべての閾値を先頭で名前付きの定数にしています。
条件式の中に直接 25.0 と書いても動きますが、変数にしておくと、
- 「いまどんな条件で絞っているか」が1か所を見れば分かる
- 条件を緩める/締めるときに、式の中身を触らずに数字だけを変えられる
- 数字に意味のある名前が付くので、
0.08が何なのか(ROE の下限)が読んで分かる
という効果があります。スクリーニングは「条件をいじって結果の変化を見る」作業を何度も
繰り返すので、この形にしておく価値は大きいです。
ファンダメンタルフィルタの意図
| 条件 | 閾値 | 意図 |
|---|---|---|
per が 0〜25 |
PER_MAX = 25.0 |
割高を除く。下限0は、PER が負の値で返ってきた場合への備え(実測では PER が負の銘柄は無く、赤字などで PER が欠損の銘柄は、後述の「欠損は不合格」の仕組みで落ちる) |
pbr が 0〜3.0 |
PBR_MAX = 3.0 |
純資産に対して極端に高い評価の銘柄を除く |
roe ≧ 8% |
ROE_MIN = 0.08 |
株主資本を使って稼げているか |
revenue_growth ≧ 0% |
REVENUE_GROWTH_MIN = 0.00 |
売上が縮んでいない(長期保有の最低条件) |
earnings_growth ≧ −10% |
EARNINGS_GROWTH_MIN = -0.10 |
利益は年ごとの振れが大きいので、多少の減益は許容 |
operating_margin ≧ 5% |
OPERATING_MARGIN_MIN = 0.05 |
本業で利益が出ているか |
gross_margin ≧ 20% |
GROSS_MARGIN_MIN = 0.20 |
価格決定力・原価構造の目安 |
free_cashflow > 0 |
FCF_MIN = 0.0 |
現金を生んでいるか(利益が出ていても現金が残らない企業を弾く) |
current_ratio ≧ 1.0 |
CURRENT_RATIO_MIN = 1.0 |
短期の支払い能力 |
debt_to_equity ≦ 100 |
DE_MAX = 100.0 |
過大な借入を除く |
売上成長率は「マイナスでないこと」、利益成長率は「−10% まで許容」と、非対称に設定して
います。利益は特別損失や一時的な費用で簡単にへこむのに対し、売上の減少はビジネスそのものの
縮小を示すことが多い——という長期投資の見方をそのまま条件に落としたものです。
欠損値の扱いが「合格側」か「不合格側」かを意識する
この条件式でいちばん注意して読むべきは fillna() です。欠損を何で埋めるかによって、
その銘柄が通るか落ちるかが決まります。
| 列 | 欠損時の埋め値 | 結果 |
|---|---|---|
earnings_growth |
0 |
0 >= -0.10 → 通す(不明な減益率を理由に落とさない) |
gross_margin |
0 |
0 >= 0.20 は偽 → 落とす(粗利益率が分からない銘柄は評価しない) |
free_cashflow |
0 |
0 > 0 は偽 → 落とす
|
current_ratio |
0 |
0 >= 1.0 は偽 → 落とす
|
debt_to_equity |
999 |
999 <= 100 は偽 → 落とす
|
fillna(999) のような一見奇妙な値は、「欠損を最悪値として扱い、確実に落とす」ための
書き方です。逆に earnings_growth だけは合格側に倒しています。ここは好みが分かれるところ
なので、厳しくしたければ fillna(-999) にすればよい、という程度の設計判断だと理解して
ください。大事なのはどちらに倒しているかを自覚していることです。
なお per / pbr / roe / revenue_growth / operating_margin には fillna() が
ありません。pandas の比較演算では欠損(NaN)との比較が常に偽になるため、欠損は自動的に
不合格になります。埋めていないのは手抜きではなく、既定の挙動がそのまま望む結果になる
ケースです。
テクニカルフィルタの意図
| 条件 | 意図 |
|---|---|
close > ma200 |
株価が200日移動平均より上=長期上昇トレンドの中にいる |
ma25 > ma75 |
短期線が中期線の上=トレンドが崩れていない |
rsi14 が 30〜75 |
売られ過ぎ・買われ過ぎの極端な位置を避ける |
vol_ma20 ≧ 10万株 |
流動性の確保。売買が薄い銘柄は、買えても売れない |
長期保有が前提なので、テクニカルは「買いタイミングを当てる」ためではなく、
明らかに落ちているものを避ける/実際に売買できる銘柄に限るために使っています。
この控えめな役割は、後半のスコアリングでテクニカルの配点を15点に抑えていることとも一貫します。
段階ごとの通過数を必ず出す
最後の print は、ファンダメンタル・テクニカル・両方それぞれの通過数を表示します。
これは飾りではなく運用上の生命線です。ある週に候補が0件になったとき、
- ファンダメンタルは通っているがテクニカルで全滅 → 相場全体が下げている
- ファンダメンタルの時点でほぼ0 → 財務データの取得に失敗している疑い
というように、どちらの条件で消えたかが分かれば原因の切り分けが一瞬で終わります。
最終結果だけを表示していると、この情報は失われます。
通過銘柄を Gold に保存する
df_screened = df[f_fundamental & f_technical].copy()
df_screened['screened_at'] = pd.Timestamp.now()
df_screened['passed_fundamental'] = True
df_screened['passed_technical'] = True
# 後段(03_scoring)が埋める列を、ここで空のまま確保しておく。
# gold は上書きのたびに全列を書き直すため、この4列を欠かすとテーブル側で
# データスキッピング統計の対象に選ばれている列が消え、書き込みが弾かれる。
df_screened['score'] = float('nan')
df_screened['rank'] = float('nan')
df_screened['ai_comment'] = '—'
df_screened['scored_at'] = pd.NaT
spark.createDataFrame(df_screened).write.format('delta').mode('overwrite') \
.option('overwriteSchema', 'true') \
.saveAsTable('stock_analysis.gold.screening_results')
print(f'\n✅ stock_analysis.gold.screening_results 保存完了: {len(df_screened)} 件')
print('\n--- スクリーニング通過銘柄(先頭20件)---')
print(
df_screened[['code', 'name', 'market_segment', 'industry_33',
'per', 'pbr', 'roe', 'revenue_growth', 'gross_margin']]
.head(20).to_string(index=False)
)
screened_at はいつのスクリーニング結果かを示すタイムスタンプです。Bronze の
retrieved_at(第1章)と同じ発想で、データに時刻を刻んでおくと、後から「この候補リストは
いつ時点のものか」を必ず答えられます。
passed_fundamental / passed_technical は、この時点では両方 True に決まっています
(両方通った行だけを残しているため)。将来「ファンダメンタルだけ通った銘柄も保存して
おきたい」と条件を緩めたときに、フラグ列としてそのまま意味を持たせられるように置いてある
列です。
score / rank / ai_comment / scored_at の4列は、この章の後半(3-A・3-B)で
初めて中身が入る列です。ここで空のまま先に用意しておくのがポイントで、理由は
mode('overwrite') にあります。
Silver と同じく mode('overwrite') なので、実行のたびに Gold は最新の候補リストに
まるごと置き換わります。このとき overwriteSchema を付けているため、書き込む
DataFrame の列構成がそのままテーブルのスキーマになります。つまりここで4列を省くと、
スクリーニングを実行した瞬間にテーブルからその4列が消え、直後のスコアリングでまた
生える、という状態を毎回くり返すことになります。
これは単に落ち着かないだけでなく、実際にジョブを止めます。Unity Catalog のマネージド
テーブルでは、Databricks がクエリの傾向を学習して「どの列のファイル統計を採ると
検索が速くなるか」を自動で選びます(データスキッピング)。ai_comment のように
後半でしか存在しない列がその対象に選ばれると、次にスクリーニングがその列を持たない
スキーマで上書きしようとした時点で、「統計の対象列が見つからない」として書き込みが
拒否されます。テーブルの列構成は、パイプラインのどの段階でも同じに保つ——
2つのノートブックが同じテーブルを書く構成では、これが安全側の作法です。
実行例: 東証全体で約 3,700 銘柄のうち、通過するのは 数十件です(2026年10月時点の一例では 32 件、
全体の 0.9%。これまでの実測では 32〜52 件の間で動きました)。この「絞れている感じ」が出ないときは、条件が緩すぎるか、
財務データがうまく取れていないかのどちらかを疑ってください。
完了確認と実行ログ
ノートブックの最後で、Silver と Gold の両方が意図どおりできているかを検証します。
# MAGIC %md
# MAGIC ## 完了確認
print('=== 完了確認 ===')
silver_cnt = spark.table('stock_analysis.silver.stock_features').count()
gold_cnt = spark.table('stock_analysis.gold.screening_results').count()
total_codes = spark.table('stock_analysis.bronze.stock_master').count()
print(f'silver.stock_features : {silver_cnt:>6,} 件')
print(f'gold.screening_results : {gold_cnt:>6,} 件')
print(f'通過率 : {gold_cnt/total_codes*100:.1f}% ({gold_cnt}/{total_codes})')
assert silver_cnt > 0, 'silver.stock_features が空'
assert gold_cnt > 0, 'gold.screening_results が空(条件緩和を検討)'
assert gold_cnt <= total_codes, 'スクリーニング通過数が総銘柄数を超えている'
# price_mom_12m カラムの存在確認
cols = spark.table('stock_analysis.silver.stock_features').columns
assert 'price_mom_12m' in cols, 'price_mom_12m カラムが存在しない'
assert 'ma200' in cols, 'ma200 カラムが存在しない'
assert 'rsi14' in cols, 'rsi14 カラムが存在しない'
print('\n✅ 全チェック通過')
print(f'\n次のステップ → スコアリング実装(03_scoring.py)')
すべて通ると、次のように表示されます(件数は一例)。
=== 完了確認 ===
silver.stock_features : 3,691 件
gold.screening_results : 32 件
通過率 : 0.9% (32/3702)
✅ 全チェック通過
次のステップ → スコアリング実装(03_scoring.py)
assert は「このノートブックが成功したと言える条件」を機械可読な形で書いたものです。
特に gold_cnt > 0 は重要で、条件を締めすぎて候補が0件になったまま気付かず、後続の処理が
空回りするという事故を防ぎます。定期実行(第4章)に載せると人が結果を見なくなるので、
この手の下限チェックが最後の砦になります。
列の存在チェック(price_mom_12m / ma200 / rsi14)は、このあとの処理で使う列を
先回りして守っているものです。price_mom_12m と ma200 は後半のスコアリングで長期トレンド
15点の計算に使い、ma200 と rsi14 は先ほどのスクリーニングのフィルタにも使いました。
これらが欠けていることがここで分かれば、スコアリングまで進んでから落ちるより早く原因に届きます。
最後に、実行の記録を gold.run_logs に1行追記します。
# 実行ログ記録(明示的スキーマで None 型推論エラーを回避)
from pyspark.sql.types import StructType, StructField, StringType, IntegerType, DoubleType
log_schema = StructType([
StructField('run_date', StringType(), True),
StructField('phase', StringType(), True),
StructField('record_count', IntegerType(), True), # そのフェーズの件数(screening: 通過銘柄数)
StructField('top_score', DoubleType(), True),
StructField('status', StringType(), True),
])
log_record = [(
pd.Timestamp.now().isoformat(),
'screening',
int(gold_cnt),
None,
'success',
)]
spark.createDataFrame(log_record, schema=log_schema) \
.write.format('delta').mode('append') \
.option('mergeSchema', 'true') \
.saveAsTable('stock_analysis.gold.run_logs')
print('✅ run_logs 記録完了')
run_logs は、データ収集・スクリーニング・スコアリングの3工程が共通のスキーマで
書き込む実行ログです。この段階では最高スコアがまだ存在しないので top_score は None。
None を含む行を createDataFrame に渡すと型推論が失敗するため、スキーマを明示して
います(第1章と同じ作法)。ログの活用方法は第4章の定期実行でまとめて扱います。
後半: スコアリング(03_scoring.py)
ここからノートブックが変わります。前半で絞り込んだ候補に、点数と言葉をつけていきます。
# MAGIC %md
# MAGIC # スコアリング実装
# MAGIC
# MAGIC スクリーニング通過銘柄にルールベーススコア(100点満点)を付与し、上位30銘柄に
# MAGIC Databricks Foundation Model API で長期保有目線のAIコメントを生成する(gold層更新)。
import pandas as pd
import time
from pyspark.sql import SparkSession
spark = SparkSession.builder.getOrCreate()
print('Libraries loaded OK')
このノートブックは外部ライブラリを追加しません。pandas と Spark、そして後半で使う
mlflow.deployments(Databricks 標準搭載)だけで完結します。
再実行できるように読み込む — 冪等性
# MAGIC %md
# MAGIC ## 3-A. ルールベーススコアリング(合計100点)
df_screened = spark.table('stock_analysis.gold.screening_results').toPandas()
# 冪等化: 本ノートブックが再生成する列を読み込み直後に落とす。
# gold を読み直す構成のため、これが無いと再実行時に ai_comment が merge で
# ai_comment_x/_y に分裂し KeyError になる(初回は列が無いので通るが2回目以降落ちる)。
df_screened = df_screened.drop(columns=['ai_comment', 'scored_at'], errors='ignore')
print(f'スクリーニング通過銘柄数: {len(df_screened):,}')
このノートブックは gold.screening_results を読んで、同じテーブルに書き戻す構造です。
そのため2回目以降の実行では、前回付けた ai_comment / scored_at が入力に混ざっています。
そのまま後半の merge を通すと、pandas が同名列を ai_comment_x / ai_comment_y に分裂
させ、結合結果 df_final['ai_comment'] の参照(後で出てきます)が KeyError で落ちます。
初回は通るのに2回目から落ちる——定期実行で最も嫌なタイプの不具合です。読み込んだ直後に
「自分がこれから作る列」を落としておくことで、何回実行しても同じ結果になる(冪等)
形にしています。errors='ignore' を付けているので、列がまだ無い初回でもエラーになりません。
ルールベーススコア(100点満点)
スコア関数の全文です。長いですが、やっていることは「指標ごとに閾値で刻んで点を足す」の
繰り返しだけです。
def calculate_score(row):
score = 0
# ── 収益品質 (30点) ──────────────────────────────────────────────
gross = row.get('gross_margin') or 0
if gross >= 0.50: score += 12
elif gross >= 0.40: score += 9
elif gross >= 0.30: score += 6
elif gross >= 0.20: score += 3
fcf_m = row.get('fcf_margin') or 0
if fcf_m >= 0.15: score += 10
elif fcf_m >= 0.10: score += 8
elif fcf_m >= 0.05: score += 5
elif fcf_m >= 0.00: score += 2
roa = row.get('roa') or 0
if roa >= 0.10: score += 8
elif roa >= 0.07: score += 6
elif roa >= 0.05: score += 4
elif roa >= 0.03: score += 2
# ── 成長性 (20点) ────────────────────────────────────────────────
rg = row.get('revenue_growth') or 0
if rg >= 0.20: score += 10
elif rg >= 0.10: score += 8
elif rg >= 0.05: score += 5
elif rg >= 0.00: score += 2
eg = row.get('earnings_growth') or 0
if eg >= 0.20: score += 10
elif eg >= 0.10: score += 8
elif eg >= 0.05: score += 5
elif eg >= 0.00: score += 2
# ── バリュエーション (20点) ──────────────────────────────────────
roe = row.get('roe') or 0
if roe >= 0.20: score += 8
elif roe >= 0.15: score += 6
elif roe >= 0.10: score += 4
elif roe >= 0.08: score += 2
per = row.get('per') or 99
if per <= 10: score += 7
elif per <= 15: score += 6
elif per <= 20: score += 4
elif per <= 25: score += 2
pbr = row.get('pbr') or 99
if pbr <= 1.0: score += 5
elif pbr <= 1.5: score += 4
elif pbr <= 2.0: score += 3
elif pbr <= 3.0: score += 1
# ── 財務健全性 (15点) ────────────────────────────────────────────
de = row.get('debt_to_equity') or 999
if de <= 30: score += 7
elif de <= 60: score += 5
elif de <= 80: score += 3
elif de <= 100: score += 1
cr = row.get('current_ratio') or 0
if cr >= 2.0: score += 5
elif cr >= 1.5: score += 4
elif cr >= 1.2: score += 3
elif cr >= 1.0: score += 2
ev_ebitda = row.get('enterprise_to_ebitda') or 999
if ev_ebitda <= 8: score += 3
elif ev_ebitda <= 12: score += 2
elif ev_ebitda <= 16: score += 1
# ── 長期トレンド (15点) ──────────────────────────────────────────
close = row.get('close') or 0
ma200 = row.get('ma200') or 1
trend = (close - ma200) / ma200 if ma200 > 0 else 0
if trend >= 0.20: score += 8
elif trend >= 0.10: score += 6
elif trend >= 0.05: score += 4
elif trend >= 0.00: score += 2
mom12 = row.get('price_mom_12m') or 0
if mom12 >= 0.20: score += 7
elif mom12 >= 0.10: score += 5
elif mom12 >= 0.00: score += 3
else: score += 1
return min(score, 100)
指標の意味(ひとこと)
配点表に出てくる財務指標を、先に一言ずつ確認しておきます。金融の知識は前提にしません。
| 指標 | 意味 | 見方 |
|---|---|---|
| 粗利益率 | 売上のうち、原価を引いても手元に残る割合 | 高いほど価格決定力がある |
| FCF マージン | 売上のうち、自由に使える現金として残る割合(前半で自前計算した列) | 高いほど現金を生む |
| ROA | 会社の総資産に対する、利益の割合 | 高いほど資産を効率よく使っている |
| ROE | 株主が出したお金(自己資本)に対する、利益の割合 | 高いほど株主のお金で稼げている |
| PER | 株価が、1株あたりの利益の何倍か | 低いほど利益に対して割安 |
| PBR | 株価が、1株あたりの純資産の何倍か | 低いほど資産に対して割安 |
| D/E 比率 | 自己資本に対する、借入などの負債の割合。本記事のデータでは 100 が「負債が自己資本と同額」を表す尺度(中央値は約31) | 低いほど借入が少ない |
| 流動比率 | 1年以内に返す負債に対する、1年以内に現金化できる資産の割合 | 高いほど短期の支払いに余裕がある |
| EV/EBITDA | 会社を丸ごと買う値段が、本業で稼ぐ利益(EBITDA)の何年分か | 低いほど利益に対して割安 |
配点の全体像
| カテゴリ | 指標 | 配点 | 刻み |
|---|---|---|---|
| 収益品質 (30点) | 粗利益率 | 12点 | ≥50%→12, ≥40%→9, ≥30%→6, ≥20%→3 |
| FCF マージン | 10点 | ≥15%→10, ≥10%→8, ≥5%→5, ≥0%→2 | |
| ROA | 8点 | ≥10%→8, ≥7%→6, ≥5%→4, ≥3%→2 | |
| 成長性 (20点) | 売上成長率 | 10点 | ≥20%→10, ≥10%→8, ≥5%→5, ≥0%→2 |
| 利益成長率 | 10点 | ≥20%→10, ≥10%→8, ≥5%→5, ≥0%→2 | |
| バリュエーション (20点) | ROE | 8点 | ≥20%→8, ≥15%→6, ≥10%→4, ≥8%→2 |
| PER | 7点 | ≤10→7, ≤15→6, ≤20→4, ≤25→2 | |
| PBR | 5点 | ≤1.0→5, ≤1.5→4, ≤2.0→3, ≤3.0→1 | |
| 財務健全性 (15点) | D/E 比率 | 7点 | ≤30→7, ≤60→5, ≤80→3, ≤100→1 |
| 流動比率 | 5点 | ≥2.0→5, ≥1.5→4, ≥1.2→3, ≥1.0→2 | |
| EV/EBITDA | 3点 | ≤8→3, ≤12→2, ≤16→1 | |
| 長期トレンド (15点) | MA200 乖離率 | 8点 | ≥20%→8, ≥10%→6, ≥5%→4, ≥0%→2 |
| 12ヶ月モメンタム | 7点 | ≥20%→7, ≥10%→5, ≥0%→3, <0%→1 |
配点に込めた設計方針
このシステムは3〜5年の長期保有を前提にしています。配点はその立場をそのまま数値にした
ものです。
-
収益品質を最大カテゴリ(30点)に置く。粗利益率・FCF マージン・ROA は、
「そのビジネスがどれだけ効率よく現金を生むか」を表します。長期で効いてくるのは、
相場の勢いではなくこの体質です。 -
短期モメンタム指標(MACD・RSI)はスコアから完全に除外。どちらも2〜8週間の
シグナルで、3年後のリターンとはほぼ無関係だからです。RSI はスクリーニングの
「極端な位置を避ける」フィルタとしてだけ使い、点数には反映しません。 -
テクニカルの比重を15点に抑える。それも
closeとma200の乖離率と12ヶ月
モメンタムという、年単位のトレンド指標だけです。「実績として長期上昇している」
ことを評価し、MACD の代わりに置いています。 -
バリュエーション(20点)は「安さ」より「妥当さ」。PER ≤ 10 に7点満点を与える
一方、PER ≤ 25 でも2点は入ります。優良企業が適正価格で買える状態を排除しないための
刻みです。
実装上の細かい工夫
row.get('列名') or 0 の意味。 None や 0 のときに既定値を使うイディオムです。
ここでも「欠損を有利に扱うか不利に扱うか」を指標ごとに変える意図で書かれています。
- 高いほど良い指標(粗利益率・ROA・ROE・流動比率など)→
or 0(最低点扱い) - 低いほど良い指標(PER・PBR・D/E・EV/EBITDA)→
or 99/or 999(同じく最低点扱い)
どちらも「データが無い銘柄に点はあげない」方向を狙っています。欠損を有利に倒すと、
情報が少ない銘柄ほど上位に来るという最悪の挙動になります。
ただし、toPandas() した行の欠損は None ではなく NaN で、NaN は or では置き換わりません
(float('nan') or 0 は nan のままです)。それでも多くの指標では、NaN との比較が常に偽に
なるため、どの閾値にも当たらず、結果的に「点が入らない」扱いになります。例外が2つあります。
ma200 が NaN だと trend が 0 になって2点、price_mom_12m が NaN だと最後の else で
1点が入ります。この2つはスクリーニングを通った銘柄では欠損しない(実測でも欠損は0件)ので
ここでは問題になりませんが、この書き方を欠損の多いデータに流用するときは注意してください。
欠損を確実に既定値へ置き換えたいときは、pd.isna() で明示的に判定します。
ma200 の既定値だけ 1。 これは点数のためではなく、(close - ma200) / ma200 の
ゼロ除算を防ぐためです。直後に if ma200 > 0 でも守っており、二重の防御になっています。
min(score, 100) の上限。 各カテゴリの満点を足すとちょうど100点なので、通常は
上限に当たりません。将来配点を増やしたときに、100点満点という約束が静かに壊れないための
保険です。
スコアとランクを付ける
df_screened['score'] = df_screened.apply(calculate_score, axis=1)
df_screened['rank'] = df_screened['score'].rank(ascending=False, method='min').astype(int)
df_screened = df_screened.sort_values('score', ascending=False).reset_index(drop=True)
print(f'スコア計算完了: {len(df_screened)} 銘柄')
print(f'スコア分布: min={df_screened["score"].min()} max={df_screened["score"].max()} mean={df_screened["score"].mean():.1f}')
print('\n--- スコア上位10銘柄 ---')
print(df_screened[['rank', 'code', 'name', 'score']].head(10).to_string(index=False))
最初の2行は次のように表示され、続けて上位10銘柄の表が出ます(一例)。
スコア計算完了: 32 銘柄
スコア分布: min=45 max=90 mean=64.6
rank(ascending=False, method='min') は、スコアが高いほど1位に近い順位を付けます。
method='min' は同点を同順位にする指定です(同点が2件あれば「1位・1位・3位」)。
投資候補の順位で同点に無理やり差を付ける意味はないので、この挙動が自然です。
スコア分布(最小・最大・平均)を毎回出しているのも意図的です。分布が前週と大きく違えば、
データ取得か条件設定に何かが起きているというサインになります。2026年10月時点の一例では、通過銘柄
32件に対してスコアは 45〜90 点の範囲に散らばりました。全員が90点台になる
ようなら配点が甘すぎ、全員が50点未満なら厳しすぎる、という調整の物差しにもなります。
AIコメント生成(Databricks Foundation Model API)
ここからがこの章のもう一つの山場です。点数だけでは「なぜこの銘柄が上位なのか」が
伝わりません。そこで上位30銘柄に、長期保有目線の短い評価コメントを生成します。
# MAGIC %md
# MAGIC ## 3-B. AIコメント生成(Databricks Foundation Model API・上位30銘柄)
import mlflow.deployments
TOP_N = 30
# AIコメント生成は 8B だと日本語が崩れ事実を捏造する(例: 実データと矛盾する「大幅売上減少」等)。
# 実在・疎通実績のある 70B に差し替え、接地された自然な日本語コメントを得る。
FMAPI_MODEL = 'databricks-meta-llama-3-3-70b-instruct'
client = mlflow.deployments.get_deploy_client('databricks')
def generate_ai_comment(row):
roe = row.get('roe', 0) or 0
roa = row.get('roa', 0) or 0
gross = row.get('gross_margin', 0) or 0
fcf_m = row.get('fcf_margin', 0) or 0
om = row.get('operating_margin', 0) or 0
rg = row.get('revenue_growth', 0) or 0
eg = row.get('earnings_growth', 0) or 0
de = row.get('debt_to_equity', 0) or 0
cr = row.get('current_ratio', 0) or 0
prompt = f"""以下の東証上場銘柄を、3〜5年の長期保有を前提に分析してください。
一時的な株価変動ではなく、ビジネスモデルの持続性・競争優位性・財務健全性を重視し、
3文以内の評価コメントを日本語で出力してください。
また、主なリスク要因(業種サイクル・債務・競合)があれば1文で触れてください。
必ず守ること:
- 下に示す数値と業種のみを根拠に述べる。具体的な製品名・地域・買収・社名変更・提携など、
与えられていない事実を創作しない(分からないことは述べない)。
- 数値と矛盾する主張をしない(例: 売上成長率がプラスなのに「売上減少」と書かない)。
銘柄名: {row['name']}(証券コード: {row['code']})
業種: {row['industry_33']}
ROE: {roe:.1%} ROA: {roa:.1%}
粗利益率: {gross:.1%} FCFマージン: {fcf_m:.1%} 営業利益率: {om:.1%}
売上成長率: {rg:.1%} 利益成長率: {eg:.1%}
PER: {row.get('per', 'N/A')} PBR: {row.get('pbr', 'N/A')}
D/E: {de:.0f} 流動比率: {cr:.1f}
スコア: {row['score']}/100
コメント:"""
try:
response = client.predict(
endpoint=FMAPI_MODEL,
inputs={'messages': [{'role': 'user', 'content': prompt}], 'max_tokens': 200}
)
return response['choices'][0]['message']['content'].strip()
except Exception as e:
return f'コメント生成失敗: {e}'
外部トークン不要で LLM を呼ぶ
Databricks Free Edition はアウトバウンド通信に制限があり、外部の推論 API には到達
できません(第1章で見たとおりです)。しかし Databricks 内部の Foundation Model API
なら、ワークスペース認証がそのまま使われるため、API キーも Secrets も設定せずに
LLM を呼べます。
client = mlflow.deployments.get_deploy_client('databricks')
response = client.predict(endpoint=..., inputs={'messages': [...], 'max_tokens': 200})
第1部・第5章で MLflow のトレーシングを試したときと同じ呼び出し方です。この「ワークスペース
の中で完結する LLM」は、Free Edition で AI を扱ううえで最大の武器になります。
プロンプトに「接地」の制約を書く
プロンプトの構造を見てください。役割の指示 → 守るべき制約 → 数値データ → 出力の合図、
という順番になっています。中でも重要なのが制約の2行です。
- 与えた数値と業種のみを根拠にする(製品名・地域・買収などを創作しない)
- 数値と矛盾する主張をしない(売上成長率がプラスなのに「売上減少」と書かない)
LLM は知らないことを尋ねられると、それらしい話を作ってしまいます。株式の分析コメントで
これが起きると、存在しない買収や提携を根拠に「買い」と読める文章が出てきかねません。
「与えたデータの外に出るな」と明示的に縛ることを、一般に**接地(グラウンディング)**と
呼びます。ここでは制約文と、数値を整形して渡すこと({roe:.1%} のように単位を確定させて
渡す)の2つで接地しています。
失敗しても止めない
try / except で例外を捕まえ、失敗時は 'コメント生成失敗: {e}' という文字列を返して
います。30銘柄のうち1件が失敗しただけでノートブック全体が落ちると、スコアリング結果まで
保存されません。第1章の yfinance と同じ「外部呼び出しは失敗するもの」という前提に
立ち、失敗を値として持ち帰る設計です。
上位30銘柄にコメントを付ける
df_top = df_screened.head(TOP_N).copy()
print(f'AIコメント生成対象: 上位{TOP_N}銘柄')
comments = []
for i, (_, row) in enumerate(df_top.iterrows()):
comments.append(generate_ai_comment(row))
if (i + 1) % 10 == 0:
print(f' {i+1}/{TOP_N} 件生成済み')
time.sleep(1)
df_top['ai_comment'] = comments
print(f'✅ AIコメント生成完了: {len(df_top)} 件')
30件の生成中は、10件ごとに進捗が出ます。
AIコメント生成対象: 上位30銘柄
10/30 件生成済み
20/30 件生成済み
30/30 件生成済み
✅ AIコメント生成完了: 30 件
なぜ全件ではなく上位30件なのか。 LLM の呼び出しは、テーブルの計算に比べて桁違いに
遅く、コストもかかります。一方で、人間が実際に読むのは上位の数十件です。スコアは全通過
銘柄に付けたうえで、コメントは読まれるところにだけ生成する——という切り分けにして
います。「全部に AI をかける」のではなく「効くところにかける」という判断は、AI を業務
システムに載せるときに何度も出てくる論点です。
time.sleep(1) は、エンドポイントへのレート制限に配慮した間隔調整です。10件ごとの
進捗表示とあわせて、第1章の yfinance バッチ取得と同じ作法になっています。
Gold テーブルを更新する
df_final = df_screened.merge(df_top[['code', 'ai_comment']], on='code', how='left')
df_final['ai_comment'] = df_final['ai_comment'].fillna('—')
df_final['scored_at'] = pd.Timestamp.now()
spark.createDataFrame(df_final).write.format('delta').mode('overwrite') \
.option('overwriteSchema', 'true') \
.saveAsTable('stock_analysis.gold.screening_results')
print(f'\n✅ stock_analysis.gold.screening_results 更新完了: {len(df_final)} 件(score/rank/ai_comment付与)')
print('\n--- スコア上位10銘柄(AIコメント付き) ---')
for _, row in df_final.sort_values('score', ascending=False).head(10).iterrows():
print(f"[{row['rank']}位 / {row['score']}点] {row['name']}({row['code']})")
print(f" {row['ai_comment']}\n")
全通過銘柄(df_screened)に、上位30件のコメント(df_top)を left 結合します。
31位以下はコメントが欠損になるので、fillna('—') で全角ダッシュを入れておきます。
None のままにしないのは、後でダッシュボード(第7章)や Genie(第5章)が参照したときに
「未生成」であることが表示として自然に伝わるようにするためです。
scored_at で採点時刻を刻み、overwriteSchema を付けて書き戻します。score / rank /
ai_comment / scored_at の4列は、スクリーニングの時点で空のまま用意してあるので、
列が増えるわけではありません。変わるのは列の型です。スクリーニング時点の score / rank は
欠損だけの浮動小数(double)の列でしたが、ここで整数(bigint)になります。この型の変更を
テーブルに許可するために overwriteSchema を付けています(第1部・第3章で扱った
スキーマ進化と同じ考え方です)。
これで stock_analysis.gold.screening_results は、候補・順位・点数・コメントが
揃った、そのまま人に見せられるテーブルになりました。
完了確認と実行ログ
# MAGIC %md
# MAGIC ## 完了確認
print('=== 完了確認 ===')
gold_tbl = spark.table('stock_analysis.gold.screening_results')
cols = gold_tbl.columns
gold_cnt = gold_tbl.count()
for c in ['score', 'rank', 'ai_comment', 'scored_at', 'fcf_margin']:
assert c in cols, f'{c} カラムが存在しない'
stats = gold_tbl.selectExpr('min(score) as min_s', 'max(score) as max_s').toPandas().iloc[0]
ai_comment_cnt = gold_tbl.filter("ai_comment IS NOT NULL AND ai_comment != '—'").count()
print(f'gold.screening_results : {gold_cnt:>6,} 件')
print(f'スコア範囲 : {stats["min_s"]:.0f} ~ {stats["max_s"]:.0f}')
print(f'AIコメント付与件数 : {ai_comment_cnt} 件')
assert stats['min_s'] is not None, 'score が計算されていない'
assert gold_cnt > 0, 'gold.screening_results が空'
assert ai_comment_cnt == min(30, gold_cnt), 'AIコメントが上位30銘柄分付与されていない'
print('\n✅ 全チェック通過')
print('\n次のステップ → 定期実行・運用設定(04_schedule_setup.py)')
すべて通ると、次のように表示されます(一例)。
=== 完了確認 ===
gold.screening_results : 32 件
スコア範囲 : 45 ~ 90
AIコメント付与件数 : 30 件
✅ 全チェック通過
次のステップ → 定期実行・運用設定(04_schedule_setup.py)
チェックの中で目を引くのが最後の1行です。
assert ai_comment_cnt == min(30, gold_cnt), 'AIコメントが上位30銘柄分付与されていない'
'—' 以外が入っている行数が、上位30件(通過銘柄が30件未満ならその全件)と一致する
ことを確かめています。LLM 呼び出しが失敗しても try / except のおかげで
ノートブックは走り切ってしまうので、「走った」ことと「コメントが付いた」ことを分けて
確かめる、という発想のチェックです。
ただし、このチェックには限界があります。生成に失敗した行には 'コメント生成失敗: …' という
文字列が入っていて、これは '—' ではないので、数に含まれてしまいます。つまりこの assert が
保証するのは「コメント欄が埋まった行数」までで、30件すべてが失敗していても通ります。
失敗の有無やコメントの内容が正しいかは、このチェックでは分かりません。実行後に
コメント生成失敗 を含む行が無いかを確かめるほか、内容の検証は第6章の評価の仕組みで扱います。
生成 AI を組み込んだ処理では、「走った」と「意図どおりできた」を分けて確かめることが
特に大事です。
最後に実行ログを追記します。スクリーニングのときと同じスキーマですが、こちらは
top_score に実際の最高スコアが入ります。
# 実行ログ記録(明示的スキーマで None 型推論エラーを回避)
from pyspark.sql.types import StructType, StructField, StringType, IntegerType, DoubleType
log_schema = StructType([
StructField('run_date', StringType(), True),
StructField('phase', StringType(), True),
StructField('record_count', IntegerType(), True), # そのフェーズの件数(scoring: スコア付与銘柄数)
StructField('top_score', DoubleType(), True),
StructField('status', StringType(), True),
])
log_record = [(
pd.Timestamp.now().isoformat(),
'scoring',
int(gold_cnt),
float(stats['max_s']),
'success',
)]
spark.createDataFrame(log_record, schema=log_schema) \
.write.format('delta').mode('append') \
.option('mergeSchema', 'true') \
.saveAsTable('stock_analysis.gold.run_logs')
print('✅ run_logs 記録完了')
run_date / phase / record_count / top_score / status という共通スキーマに
3工程すべてが書き込むので、run_logs を1本読むだけで「今週の実行で何件収集し、何件通過し、
最高スコアは何点だったか」が時系列で追えます。この設計が第4章の定期実行で活きてきます。
コラム:8B と 70B — 小さいモデルが作り出した「事実」
AIコメントの生成モデルは、当初 databricks-meta-llama-3-1-8b-instruct(8B)を使って
いました。動きはしたのですが、出てきた日本語を読むと様子がおかしいことに気付きます。
何が起きたか。 日本語表現が不自然に硬いだけならまだよかったのですが、深刻だったのは
与えたデータと矛盾する断定でした。売上成長率がプラスの数値としてプロンプトに書いて
あるのに、コメントは「大幅な売上減少が続いており」と述べる。プロンプトに一切書いていない
製品名や事業展開を、さも既知の事実のように語る。株式の分析コメントとしては、間違って
いるだけでなく、判断を誤らせる方向に間違っているという最悪の状態です。
対処。 2つ同時に打ちました。
-
モデルを 70B(
databricks-meta-llama-3-3-70b-instruct)に変更。日本語の自然さと、
与えた数値への忠実さが目に見えて改善しました。 -
プロンプトに接地の制約を追加(本文で見た「与えた数値のみを根拠に」「数値と矛盾する
主張をしない」の2行)。モデルを変えるだけでなく、創作を明示的に禁じる指示を足しました。
教訓。
- **小さいモデルは「動くが正しくない」**という失敗の仕方をする。エラーで落ちてくれれば
気付けますが、それらしい日本語が返ってくると成功に見えてしまいます。生成物を必ず
人間が読んで確かめる工程が要ります。 -
日本語の品質はモデルの規模に敏感。 英語では十分に見える小型モデルでも、日本語では
表現が崩れ、事実の取り違えも増える傾向がありました。 -
モデルを差し替えても、プロンプトの接地は省けない。 70B でも制約なしなら創作は
起こりえます。モデル選択とプロンプト設計は、どちらか一方では足りません。 -
エンドポイント名は実在を必ず確認する。 手元のワークスペースに存在しないモデル名を
書いても、その場では気付けないことがあります。Free Edition では
databricks-meta-llama-3-3-70b-instruct/databricks-meta-llama-3-1-8b-instruct/
databricks-llama-4-maverickなどが利用でき、一覧は Serving のエンドポイント一覧で
確認できます。
そして、この一件がそのまま次の問いにつながります。「AI の出力が正しいかどうかを、
毎回人間が読む以外の方法で確かめられないのか?」 —— それに答えるのが、第6章で扱う
LLMOps(トレーシング・評価・LLM-as-a-judge)です。
まとめ
-
Gold 層は「そのまま意思決定に使える形」。絞る(スクリーニング)→ 順位と言葉を
つける(スコアリング)の2段階で作る - 結合は「判断に使う列は
inner、見せる列はleft」で使い分ける - スクリーニングの閾値はすべて名前付き変数に出す。条件をいじる作業が繰り返し発生するため
-
fillna()は「欠損を合格側に倒すか不合格側に倒すか」の意思表明。無自覚に書かない -
段階ごとの通過数(ファンダメンタル/テクニカル/両方)を必ず出す。0件になったときの
原因切り分けが一瞬で終わる - スコアは長期保有の立場をそのまま配点に落とす。収益品質30点を最大にし、短期モメンタム
(MACD・RSI)は除外、テクニカルは年単位の指標で15点まで - Gold を読んで Gold に書くノートブックは、読み込み直後に再生成列を落として冪等化する
(初回は通り2回目で落ちる不具合を防ぐ) - AIコメントは Databricks Foundation Model API で外部トークンなしに生成。接地の制約を
プロンプトに明記し、上位30件だけに絞り、失敗は例外で握って処理を止めない - 生成 AI を含む処理では、「走った」と「意図どおりできた」を分けて確かめる。
assertで守れるのは件数まで(失敗行の検出や内容の正しさは別に確かめる)
次章(定期実行)へ: これで Bronze → Silver → Gold のパイプラインが1本つながりました。
しかし現状は、3つのノートブックを手で順番に実行している状態です。次章では、この
一連の流れを Databricks の Workflow ジョブとして 毎週日曜に自動実行する形に載せ替え、
gold.run_logsを使った運用の目を入れます。自動化したあとで、その結果を Genie(第5章)・
独自エージェント(第6章)・ダッシュボード(第7章)から使い倒していきます。
第4章 定期実行・運用設定 — 週次パイプラインに載せる
前章までで、Bronze → Silver → Gold のパイプラインが1本つながりました。ただし今の状態は、
3つのノートブックを 人間が順番に開いて実行している だけです。これでは「先週の結果を
見ている」ことに気付けませんし、実行順を間違えれば、スコアリング前の空っぽの Gold を
Genie やダッシュボードが参照してしまいます。
この章では、この一連の流れを Databricks の Workflow ジョブ(Jobs & Pipelines)として
毎週日曜 7:00(JST)に自動実行する形に載せ替えます。あわせて、実行の履歴を残す
stock_analysis.gold.run_logs の設計と、その確認方法を扱います。自動化とは実行を手放す
ことなので、手放した先を後から確認できる仕組みとセットで初めて成立します。
この章のノートブック:
notebooks/04_schedule_setup.py(本章のコードは、最初のrun_logs記録セル〔01_collect_data.py末尾の引用〕を除き、このノートブックと一致します)
カタログ規約: 参照はstock_analysis.gold.run_logs。ジョブが実行するのはnotebooks/01〜03
章の構成は、ノートブックの流れそのままです。
- 実行ログ設計(
gold.run_logs)を確認する(コード) - GUI でジョブを作成し、スケジュールを設定する(画面操作)
- 作成したジョブを API 経由で取得し、構成と実行結果を検証する(コード)
- 運用メモ(更新頻度・クォータ・VACUUM)
なぜジョブ作成を GUI でやるのか
Databricks のジョブは、DABs(Databricks Asset Bundles)や CLI、REST API からも定義できます。
本記事があえて GUI(Jobs & Pipelines 画面)で作るのは、Genie やダッシュボードと同じ理由です。
画面の設定だけで週次パイプラインが立つ手軽さこそ、このプラットフォームの持ち味だからです。
YAML を書いてデプロイする方法を先に見せると、「そこまでしないと自動化できない」という
印象になってしまいます。個人が1つのパイプラインを回すだけなら、画面で3タスク並べて
トリガーを1つ足せば終わりです。
ただし GUI で作ったものは、そのままでは何が設定されているか手元に残りません。そこで
本章では、作成そのものは GUI で行い、「意図どおりに作られたか」の検証はコード(SDK)で
行うという組み合わせを取ります。これは次章の Genie でも使う進め方です。
実行ログ設計(gold.run_logs)
自動実行を始めると、「先週の日曜、ちゃんと動いたのか」「何件通過したのか」を後から
知りたくなります。そのための土台が stock_analysis.gold.run_logs です。
ここでのポイントは、専用のログ記録タスクを設けないことです。ジョブに4つ目の
「ログ書き込みタスク」を足すのではなく、01/02/03 の各ノートブックが、自分の
処理の最後に自分の結果を1行追記します。
# 実行ログ記録(明示的スキーマで None 型推論エラーを回避)
from pyspark.sql.types import StructType, StructField, StringType, IntegerType, DoubleType
log_schema = StructType([
StructField('run_date', StringType(), True),
StructField('phase', StringType(), True),
StructField('record_count', IntegerType(), True), # そのフェーズの件数(collect: 収集成功銘柄数)
StructField('top_score', DoubleType(), True),
StructField('status', StringType(), True),
])
log_record = [(
pd.Timestamp.now().isoformat(),
'collect',
int(len(all_prices)),
None,
'success',
)]
spark.createDataFrame(log_record, schema=log_schema) \
.write.format('delta').mode('append') \
.option('mergeSchema', 'true') \
.saveAsTable('stock_analysis.gold.run_logs')
print('✅ run_logs 記録完了')
これは第1章で見た 01_collect_data.py 末尾のセルです。02_screening.py は 'screening' と
通過銘柄数を、03_scoring.py は 'scoring' とスコア付与件数・最高スコアを、同じ形で
追記します。週次ジョブが3タスクを順に流すと、1回の実行につき3行が積み上がる、という
設計です。
| 列 | 型 | 意味 |
|---|---|---|
run_date |
string | 実行時刻(ISO8601。サーバーレスの時計のため UTC で、オフセットは付かない。JST は +9 時間) |
phase |
string |
collect / screening / scoring
|
record_count |
int | そのフェーズの件数(collect: 収集成功銘柄数 / screening: 通過数 / scoring: 付与数) |
top_score |
double | 最高スコア(scoring のみ。他は NULL) |
status |
string | success |
なぜ「各ノートブックが自分で書く」のか
ログ専用タスクを最後に置く構成だと、そのタスクは前段の結果を自分では知らないため、
Gold テーブルを数え直すなどの二度手間が必要になります。しかも 01 が落ちて 02 以降が
実行されなかった場合、ログ行は1行も残りません。
各ノートブックが自分の末尾で書く形なら、成功したフェーズの分だけログが残ります。
collect の行はあるのに screening の行がない週があれば、そこで止まったと一目で分かります。
ログは「全部成功した記録」ではなく、どこまで進んだかの足跡として設計するのが実用的です。
ただし status が示すのは「ノートブックが最後まで走り切った」ことだけで、中身が十分だったかは
教えてくれません。たとえば collect の record_count は、通常は約 3,700 件ですが、取得できた銘柄が
少なかった週には 2,500〜2,900 件台まで減ります。それでも status は success です。件数が通常より大きく
減った週は、success の行があっても「取得が薄い週」として疑ってください。
書き込みの2つの工夫
コードには、地味ですが効く工夫が2つ入っています。
1つ目は明示的なスキーマ指定です。 top_score は collect / screening では None です。
スキーマを渡さずに createDataFrame([...]) すると、Spark は値から型を推論しようとして、
中身が None だけの列で型を決められずエラーになります。StructType で
「ここは DoubleType で NULL 可」と宣言しておけば、この問題は起きません。
2つ目は mode('append') + mergeSchema です。 ログは追記していくものなので append は
当然として、mergeSchema を付けておくと、後から列を1つ足したくなったとき(例えば所要時間を
記録したくなったとき)に、既存テーブルを作り直さずに済みます。
現在のログを確認する
ノートブック最初のコードセルで、現在の run_logs を覗きます。
from pyspark.sql import SparkSession
spark = SparkSession.builder.getOrCreate()
RUN_LOGS_TBL = 'stock_analysis.gold.run_logs'
if spark.catalog.tableExists(RUN_LOGS_TBL):
print(f'✅ {RUN_LOGS_TBL} は作成済み')
print('\n--- 直近の実行ログ(新しい順) ---')
spark.sql(f"""
SELECT run_date, phase, record_count, top_score, status
FROM {RUN_LOGS_TBL}
ORDER BY run_date DESC
LIMIT 15
""").show(truncate=False)
else:
print(f'ℹ️ {RUN_LOGS_TBL} は未作成(週次ジョブ初回実行で自動生成される。実行前なら正常)')
spark.catalog.tableExists() で存在を確かめてから読んでいるのは、このノートブックを
どの順番で実行しても落ちないようにするためです。テーブルは各ノートブックの初回実行時に
自動生成されるので、まだ一度も流していなければ「未作成」と表示されるのが正常な状態です。
ORDER BY run_date DESC で新しい順に15行。週次なら直近5週分ほどが見えます。「先週は
何件通過したか」「最高スコアは動いているか」を、この1クエリで確認できます。
出力はこのような形になります(実際の実行結果。行数は実行回数で変わります)。
✅ stock_analysis.gold.run_logs は作成済み
--- 直近の実行ログ(新しい順) ---
+--------------------------+---------+------------+---------+-------+
|run_date |phase |record_count|top_score|status |
+--------------------------+---------+------------+---------+-------+
|2026-10-11T00:18:36.649434|scoring |32 |90.0 |success|
|2026-10-11T00:16:41.326856|screening|32 |NULL |success|
|2026-10-11T00:08:30.352887|collect |3702 |NULL |success|
|2026-09-27T00:15:39.107477|scoring |34 |90.0 |success|
|2026-09-27T00:13:55.612512|screening|34 |NULL |success|
|2026-09-27T00:08:06.157776|collect |2553 |NULL |success|
|2026-09-20T00:17:17.535309|scoring |35 |90.0 |success|
|2026-09-20T00:15:27.042715|screening|35 |NULL |success|
|2026-09-20T00:08:58.257183|collect |2651 |NULL |success|
|2026-09-06T01:07:42.686583|scoring |40 |88.0 |success|
|2026-09-06T01:05:58.406978|screening|40 |NULL |success|
|2026-09-06T00:58:26.749015|collect |3405 |NULL |success|
|2026-08-22T22:46:16.615001|scoring |42 |88.0 |success|
|2026-08-22T22:44:36.533121|screening|42 |NULL |success|
|2026-08-22T22:37:40.549536|collect |3713 |NULL |success|
+--------------------------+---------+------------+---------+-------+
1回の実行が collect・screening・scoring の3行になっていること、top_score が scoring の行にだけ
入っていることが読み取れます。
表示される run_date は UTC です。日曜 8:30(JST)に始まった実行なら、前日の土曜 23:30 に
ジョブが起動し、collect の行は 00:08 ごろの時刻で記録されます。JST に直すには 9 時間足します。
Workflow ジョブを作る(GUI 手順)
作りたい構成はこれだけです。
ジョブ名 : stock_screening_weekly
スケジュール: 毎週日曜 7:00 AM(JST)
コンピュート: Serverless(Free Edition)
タスク構成(順次依存):
Task 1: collect_data → notebooks/01_collect_data (depends_on: なし)
Task 2: screening → notebooks/02_screening (depends_on: collect_data)
Task 3: scoring → notebooks/03_scoring (depends_on: screening)
操作手順は次のとおりです。
- 左ナビゲーションの Jobs & Pipelines(Workflows)→ Create → Job を開く。
- ジョブ名に
stock_screening_weeklyを入力。 -
Task 1 を作成:
- Task name:
collect_data - Type:
Notebook - Source:
Workspace、Path:.../stock_analysis/notebooks/01_collect_data - Compute:
Serverless
- Task name:
-
+ Add task で Task 2 を作成:
- Task name:
screening/ Notebook:.../notebooks/02_screening -
Depends on:
collect_data
- Task name:
-
+ Add task で Task 3 を作成:
- Task name:
scoring/ Notebook:.../notebooks/03_scoring -
Depends on:
screening
- Task name:
- 右パネル Schedules & Triggers → Add trigger → Scheduled:
- Trigger type:
Scheduled - Schedule: 毎週日曜
07:00、Timezone:(UTC+09:00) Tokyo(= JST) - ※ Advanced(Cron)で指定する場合:
0 0 7 ? * SUN(Timezone はAsia/Tokyo)
- Trigger type:
- Create(保存)後、右上 Run now で手動トリガーし、3タスクが順に成功することを確認する。
メモ: 初回の Run now は、
01_collect_dataが東証全銘柄×3年分の株価・財務を
yfinance から収集するため時間がかかります。Free Edition のコンピュートクォータを
消費するので、当日の他の実行と競合しないタイミングで実行してください。
タイムゾーンと cron 式
スケジュール指定で必ず確認したいのが タイムゾーンです。指定を忘れると UTC 基準に
なり、日曜7:00 のつもりが 日曜16:00(JST) に走ります。画面で (UTC+09:00) Tokyo を
選んでおけば、サマータイムのない日本では素直に JST として扱われます。
cron 式 0 0 7 ? * SUN は Quartz 形式で、左から 秒・分・時・日・月・曜日 です。日と曜日は
どちらか一方しか指定できないため、使わない側に ? を置きます。「毎月1日」と「毎週日曜」を
同時に指定できないので、片方を ? で無効化する、という約束事です。
タスクを分ける意味
3つの処理を1つのノートブックにまとめてしまえばタスクは1つで済みますが、あえて
3タスクに分けて依存でつなぎます。理由は3つあります。
-
落ちた場所が分かる。
screeningが失敗したとき、画面上でそのタスクだけが赤くなります -
途中から再開できる。
collect_dataが成功してscoringだけ落ちたなら、収集をやり直さずに
失敗タスクだけ再実行できます。全銘柄の再収集は時間がかかるので、この差は大きいです -
依存が壊れた順番を防ぐ。
depends_onを設定しておけば、collect_dataが失敗した時点で
screening以降は実行されません。古いデータのまま先へ進まないことが保証されます
最後の点は、前章までの設計と直結します。Silver は毎回 overwrite、Gold も再生成される
前提なので、順番が狂うと「新しい Silver と古い Gold」のような不整合が生まれます。順次
依存は、その不整合を構成レベルで禁止する仕掛けです。
ジョブ構成を検証する(API)
ここからは、GUI で作ったものが意図どおりかをコードで確かめます。Databricks Python SDK の
WorkspaceClient を使い、ジョブ名で検索してタスク構成とスケジュールを表示します。
from databricks.sdk import WorkspaceClient
JOB_NAME = 'stock_screening_weekly'
w = WorkspaceClient()
jobs = [j for j in w.jobs.list(name=JOB_NAME)]
if not jobs:
print(f'ℹ️ ジョブ「{JOB_NAME}」は未検出。GUI(4-1)で作成後に本セルを再実行すること。')
else:
job = w.jobs.get(jobs[0].job_id)
s = job.settings
print(f'ジョブ名 : {s.name}')
print(f'job_id : {job.job_id}')
print(f'タスク数 : {len(s.tasks)}')
print('\n--- タスク構成 ---')
for t in s.tasks:
deps = [d.task_key for d in (t.depends_on or [])]
deps_str = ', '.join(deps) if deps else 'なし'
nb = t.notebook_task.notebook_path if t.notebook_task else '(非Notebook)'
print(f' {t.task_key:<14} depends_on={deps_str:<20} → {nb}')
print('\n--- スケジュール ---')
if s.schedule:
print(f' cron : {s.schedule.quartz_cron_expression}')
print(f' timezone: {s.schedule.timezone_id}')
print(f' status : {s.schedule.pause_status}')
else:
print(' スケジュール未設定')
メッセージ中の
4-1は、ノートブック内の節番号(本章「Workflow ジョブを作る(GUI 手順)」に
あたるセル)です。ノートブックの節番号は本記事の章番号と揃えてあります。
WorkspaceClient() は 引数なしで作れます。ノートブック内から実行する場合、認証情報は
実行中のワークスペースのものが自動で使われるため、トークンやホスト URL を書く必要は
ありません(第3章の Foundation Model API と同じ考え方です)。
出力はこのような形になります。
ジョブ名 : stock_screening_weekly
job_id : 134908749880608
タスク数 : 3
--- タスク構成 ---
collect_data depends_on=なし → /Workspace/.../notebooks/01_collect_data
screening depends_on=collect_data → /Workspace/.../notebooks/02_screening
scoring depends_on=screening → /Workspace/.../notebooks/03_scoring
--- スケジュール ---
cron : 0 0 7 ? * SUN
timezone: Asia/Tokyo
status : PauseStatus.UNPAUSED
見るべきは3点です。depends_on が鎖状につながっているか(並列に走る設定になっていないか)、
cron の曜日と時刻、そして pause_status が UNPAUSED か(SDK は列挙型をそのまま表示するので、実際の表示は PauseStatus.UNPAUSED です)。最後のひとつは見落としがちで、
一時停止のままだと、正しく作ってあってもスケジュールは永遠に発火しません。
ジョブ作成前にこのセルを実行すると「未検出」と出るだけで落ちません。GUI 手順とコード検証を
同じノートブックに同居させるため、どの順で実行しても壊れないようにしてあります。
直近の実行結果を確認する(API)
続いて、最新の実行(run)の結果を取得します。
if not jobs:
print(f'ℹ️ ジョブ「{JOB_NAME}」未検出のためスキップ(4-1 で作成後に実行)。')
else:
runs = list(w.jobs.list_runs(job_id=jobs[0].job_id, limit=1))
if not runs:
print('ℹ️ まだ実行履歴なし。GUI で「Run now」を実行後に再確認すること。')
else:
run = w.jobs.get_run(runs[0].run_id)
st = run.state
print(f'run_id : {run.run_id}')
print(f'life_cycle : {st.life_cycle_state}')
print(f'result_state : {st.result_state}')
print('\n--- タスク別 結果 ---')
for tr in (run.tasks or []):
rs = tr.state.result_state if tr.state else None
print(f' {tr.task_key:<14} {rs}')
出力はこのような形になります。
run_id : 20093663898143
life_cycle : RunLifeCycleState.TERMINATED
result_state : RunResultState.SUCCESS
--- タスク別 結果 ---
scoring RunResultState.SUCCESS
screening RunResultState.SUCCESS
collect_data RunResultState.SUCCESS
状態は RunLifeCycleState.TERMINATED のように、列挙型の名前つきで表示されます。タスクの並びは、
実行の新しい順(scoring が先頭)になる点にも注意してください。
list_runs(..., limit=1) で最新の run を1件取り、get_run() で詳細を引き直しています。
一覧の要素だけではタスクごとの内訳が取れないため、一覧で run_id を得て、詳細を取り直す
という2段構えになります。
状態は2つの軸で見ます。
-
life_cycle_state: run が今どの段階か(PENDING/RUNNING/TERMINATEDなど) -
result_state: 終わった結果どうだったか(SUCCESS/FAILED/CANCELED)
TERMINATED は「終わった」だけを意味し、成功とは限りません。成功の判定は
result_state == SUCCESS で行います。ジョブの成否をコードで判定する場面(監視スクリプトを
書くときなど)で必ず引っかかるポイントです。
タスク別のループでは、collect_data / screening / scoring それぞれの result_state が
並びます。全体が FAILED のとき、どのタスクで止まったかがここで分かります。
運用メモ
自動化したあとの運用として、次の点を押さえておきます。
- データ更新頻度: 週1回(日曜夜〜月曜朝)。中長期保有が前提なので、日次にする必要はありません
-
スクリーニング条件の見直し: 3ヶ月ごとに、市場環境に合わせて
02_screeningの閾値
パラメータを調整します(前章で閾値をすべて名前付き変数に出しておいたのはこのためです) -
Free Edition のコンピュートクォータ: 超過する場合は
01_collect_dataのバッチを
500銘柄単位に分割します - Delta Table のバキューム: 月1回、株価履歴テーブルの不要ファイルを掃除します
-
ダッシュボード: ジョブ成功後、ダッシュボードが最新の
screened_dateを表示するか確認します
最後のバキュームについては、ノートブックに実行しない形でセルを置いてあります。
# 月次メンテナンス例(通常は実行しない。手動運用時のみコメントアウトを外す)
# spark.sql('VACUUM stock_analysis.bronze.stock_prices RETAIN 168 HOURS')
# print('✅ VACUUM 完了')
print('運用メモセル(VACUUM は既定で無効化。必要時のみ手動実行)')
VACUUM は Delta テーブルの、もう参照されていない古いデータファイルを物理削除する操作です。
RETAIN 168 HOURS(7日)は「7日より古い不要ファイルを消す」という指定で、裏を返すと
7日より前にはタイムトラベルで戻れなくなるということです。第1部第3章で見たタイムトラベルは、
この保持期間の中でだけ有効です。
このセルを既定でコメントアウトしているのは、破壊的な操作だからです。ノートブックを
上から流す運用をしていると、意図せず全セルが実行されます。「実行すると戻れなくなる操作」は、
コードとして残しつつも明示的に手を動かさないと発火しない状態にしておくのが安全です。
完了確認
章の最後に、コード側とジョブ側の両方が揃っているかを確認します。
print('=== 完了確認 ===')
checks = []
# (1) 3ノートブックが run_logs 記録セルを持つ(ソースを確認)
import base64
expected_phases = {'01_collect_data': 'collect', '02_screening': 'screening', '03_scoring': 'scoring'}
base = '/Workspace/Users/<あなたのユーザー>/stock_analysis/notebooks' # ★ 自分の環境の値に置き換える
for nb, phase in expected_phases.items():
export = w.workspace.export(f'{base}/{nb}')
src = base64.b64decode(export.content).decode('utf-8')
ok = 'run_logs' in src and f"'{phase}'" in src
checks.append(ok)
print(f' {nb:<16} run_logs 記録セル: {"✅" if ok else "❌"}')
# (2) ジョブが存在し、3タスク順次依存・週次スケジュールが設定されている
if jobs:
s = w.jobs.get(jobs[0].job_id).settings
task_ok = len(s.tasks) == 3
sched_ok = s.schedule is not None and 'SUN' in (s.schedule.quartz_cron_expression or '').upper()
print(f' ジョブ 3タスク構成 : {"✅" if task_ok else "❌"}')
print(f' 週次スケジュール : {"✅" if sched_ok else "❌"}')
checks += [task_ok, sched_ok]
else:
print(' ⚠️ ジョブ未作成(GUI 4-1 で作成後、本ノートブックを再実行して確認)')
print()
if all(checks) and jobs:
print('✅ 全チェック通過(run_logs 記録 + 週次ジョブ設定)')
else:
print('ℹ️ コード側(run_logs 記録)は準備完了。残りは GUI でのジョブ作成・スケジュール設定・Run now。')
出力はこのような形になります。
=== 完了確認 ===
01_collect_data run_logs 記録セル: ✅
02_screening run_logs 記録セル: ✅
03_scoring run_logs 記録セル: ✅
ジョブ 3タスク構成 : ✅
週次スケジュール : ✅
✅ 全チェック通過(run_logs 記録 + 週次ジョブ設定)
baseの行には行末に★が付いています。<あなたのユーザー>は Databricks の
ログインユーザー(SELECT current_user()で確認できます)、stock_analysisは
ノートブックを import したフォルダ名です。★の付いた行だけ書き換えれば動きます。
前半では、w.workspace.export() でノートブックのソースを取得し、run_logs への書き込みと
自分の phase 文字列が含まれているかを確認しています。export の戻り値は base64 でエンコード
された文字列なので、base64.b64decode(...).decode('utf-8') で復号してから検索します。
これは「テーブルに行があるか」ではなく 「コードが仕込まれているか」を確認するチェックです。
run_logs は初回のジョブ実行で初めて中身ができるので、実行前でも「準備が整っているか」を
判定できるようにしてあります。
後半は、ジョブが3タスク構成で、cron に SUN が含まれているか。GUI で作ったものを、
コードの側から機械的に読み直しています。cron の曜日は SUN でも Sun でも Quartz は同じ日曜と
読むので、比べる前に .upper() で大文字にそろえています。
最後の分岐が all(checks) and jobs となっているのにも理由があります。ジョブが未作成の
とき checks にはノートブック側の3項目しか入らないので、all(checks) だけでは通って
しまうのです。「チェックがすべて真」と「チェック対象がすべて揃っている」は別の話で、
両方を見ないと空振りの成功が起こります。自動チェックを書くときによく踏む落とし穴です。
まとめ
- 手で順番に実行していたパイプラインを、Workflow ジョブ(
stock_screening_weekly)として
週次で自動実行する。ジョブ作成は GUI、検証は SDK コードという組み合わせで進める - タスクは3つに分け、
depends_onで順次依存にする。落ちた場所が分かり、途中から再開でき、
何より 古いデータのまま先へ進まない - スケジュールは タイムゾーンを必ず明示(
Asia/Tokyo)。cron0 0 7 ? * SUNの?は
「日か曜日、片方だけを指定する」ための約束。pause_statusがUNPAUSEDかも確認する - 実行ログは専用タスクを作らず、各ノートブックが自分の末尾で1行追記する。成功した
フェーズの分だけ足跡が残り、どこで止まったかが読み取れる - ログ書き込みは 明示的スキーマ(
None列の型推論エラー回避)とappend+mergeSchema - run の成否は
life_cycle_stateではなくresult_state == SUCCESSで判定する -
VACUUMのような戻れなくなる操作は、コードに残しつつ既定で無効化しておく
次章(Genie)へ: これで、日曜の朝には常に最新の候補リストが Gold に用意されている
状態になりました。ここから先の3章は、その結果をどう使うかの話です。次章では
gold.v_candidatesを Genie Agent に繋ぎ、SQL を書かずに自然言語で候補へ問いかけられる
ようにします。第1部第6章で触れた Genie を、いよいよ自分のデータで動かします。
第5章 Genie 簡易エージェント — 自然言語でスクリーニング結果に問う
前章までで、毎週日曜の朝には最新の候補リストが Gold 層に用意される状態ができました。
ここから3章は、その結果をどう使うかの話です。
まず取りかかるのは、いちばん手軽な出口である Genie です。Genie は、自然言語の質問を
SQL に翻訳して実行し、答えを返してくれる Databricks の機能です。第1部第6章で
samples.bakehouse を相手に触りましたが、今度は自分で作ったスクリーニング結果に
向けて立ち上げます。「PERが15倍以下で配当利回りが高い銘柄は?」と日本語で聞けば、
v_candidates に対する SQL が生成されて表が返ってくる、という状態が目標です。
この章のノートブック:
notebooks/05_genie_setup.py(本章のコードは全文このノートブックと一致します)
カタログ規約: 作成するのはstock_analysis.gold.v_candidates。参照元はstock_analysis.gold.screening_results
この章は3部構成です。
-
v_candidatesビューを列コメント付きで作成する(コード) - Genie Agent を作成し、指示とサンプル質問を与える(画面操作)
- 作成した Space に自然言語で問い合わせ、生成 SQL と結果を検証する(コード)
前章と同じく、作るのは GUI、確かめるのはコードという組み合わせです。
Genie には「生のテーブル」を見せない
いきなり本章のいちばん重要な設計判断から書きます。Genie に見せるのは
gold.screening_results ではなく、整形済みのビュー gold.v_candidates だけにします。
理由は、生のテーブルには Genie を迷わせる列が多すぎるからです。screening_results には
macd・bb_upper・passed_fundamental のような内部処理用の列や、結合の副産物である
ticker_x / ticker_y のような列が並んでいます。人間なら「これは使わない列だ」と分かり
ますが、Genie にとっては全部が等しく候補です。「割安な銘柄は?」と聞かれたときに
bb_lower(ボリンジャーバンド下限)を持ち出してくる、といったことが起こります。
さらに、比率の表現も揃っていません。roe は 0.15 のような小数、dividend_yield は
yfinance 側で既に % 表記、という混在状態です。この状態で「ROE が15%以上」と聞くと、
roe > 15 という桁を2つ間違えた SQL が生成されかねません。
そこで、Genie とダッシュボードの両方が参照する共通のコメント付きビューとして
v_candidates を用意します。ここで、
- 見せる列を22列に絞る
- 比率は % 表記に統一し、列名の末尾を
_pctにする(debt_to_equityだけは元データがもともと
% スケール〔100 が負債と自己資本が同額〕なので、列名はそのままにして、列コメントと後述の
Instructions に単位を書く) - 各列に日本語のコメントを付ける
という3つを済ませておきます。Genie の精度を上げる作業の大半は、プロンプトの工夫では
なくデータ側の整形である、というのがこの章の裏テーマです。
最初に SDK を更新する
本章の後半では、Python から Genie を操作するために databricks-sdk を使います。ところが、
サーバーレスの既定環境に入っている databricks-sdk は古く(筆者の環境では 0.40.0)、Genie Agent を
取得する genie.get_space() を持っていません。そのまま実行すると、後のセルで
AttributeError: 'GenieAPI' object has no attribute 'get_space' になります。
そこで、ノートブックの先頭で SDK を更新します。
%pip install -qU databricks-sdk
dbutils.library.restartPython()
restartPython() は Python のプロセスを再起動するので、それまでに作った変数は消えます。
変数を作る前、つまりノートブックの先頭で行うのはそのためです。
v_candidates ビューを作る
ビューの定義です。少し長いですが、やっていることは「列を選び、丸め、% に直し、
コメントを付ける」だけです。
from pyspark.sql import SparkSession
spark = SparkSession.builder.getOrCreate()
VIEW = 'stock_analysis.gold.v_candidates'
spark.sql(f"""
CREATE OR REPLACE VIEW {VIEW} (
rank COMMENT 'scoreの降順ランキング(1位が最高スコア)',
code COMMENT '証券コード(4桁)',
name COMMENT '銘柄名',
market_segment COMMENT '市場区分(プライム/スタンダード/グロース)',
industry_33 COMMENT '業種(33業種分類)',
current_price COMMENT '直近終値(円)',
per COMMENT '株価収益率。低いほど割安とされるが業種により適正水準が異なる',
pbr COMMENT '株価純資産倍率。低いほど割安とされる',
dividend_yield_pct COMMENT '配当利回り(%表記。yfinance側で既に%値のため換算不要)',
roe_pct COMMENT '自己資本利益率(%表記)。高いほど資本効率が良い',
roa_pct COMMENT '総資産利益率(%表記)。高いほど資産効率が良い',
gross_margin_pct COMMENT '粗利益率(%表記)。価格決定力・参入障壁の強さの目安',
fcf_margin_pct COMMENT 'FCF(フリーキャッシュフロー)マージン(%表記)。実際に現金を生む力の目安',
revenue_growth_pct COMMENT '売上成長率(%表記、前年同期比)',
earnings_growth_pct COMMENT '利益成長率(%表記、前年同期比)',
operating_margin_pct COMMENT '営業利益率(%表記)。本業の収益力の目安',
debt_to_equity COMMENT 'D/E比率(%換算値。100=負債と自己資本が同額)。低いほど財務健全性が高い',
current_ratio COMMENT '流動比率。短期支払能力の目安。高いほど安全',
momentum_12m_pct COMMENT '12ヶ月価格モメンタム(%表記、過去1年の株価騰落率)',
score COMMENT 'ルールベーススコア(100点満点)。収益品質・成長性・バリュエーション・財務健全性・長期トレンドの5カテゴリで算出',
ai_comment COMMENT '上位30銘柄に付与された、長期保有目線(ビジネスモデル持続性・競争優位性・リスク要因)のAIコメント',
screened_date COMMENT 'スクリーニング実行日'
)
AS
SELECT
rank,
code,
name,
market_segment,
industry_33,
ROUND(close, 0) AS current_price,
ROUND(per, 1) AS per,
ROUND(pbr, 2) AS pbr,
ROUND(dividend_yield, 2) AS dividend_yield_pct,
ROUND(roe * 100, 1) AS roe_pct,
ROUND(roa * 100, 1) AS roa_pct,
ROUND(gross_margin * 100, 1) AS gross_margin_pct,
ROUND(fcf_margin * 100, 1) AS fcf_margin_pct,
ROUND(revenue_growth * 100, 1) AS revenue_growth_pct,
ROUND(earnings_growth * 100, 1) AS earnings_growth_pct,
ROUND(operating_margin * 100, 1) AS operating_margin_pct,
ROUND(debt_to_equity, 0) AS debt_to_equity,
ROUND(current_ratio, 1) AS current_ratio,
ROUND(price_mom_12m * 100, 1) AS momentum_12m_pct,
ROUND(score, 0) AS score,
COALESCE(ai_comment, '—') AS ai_comment,
DATE(screened_at) AS screened_date
FROM stock_analysis.gold.screening_results
ORDER BY rank
""")
spark.sql(f"""
COMMENT ON TABLE {VIEW} IS
'東証プライム・スタンダード・グロース市場から、ファンダメンタル指標(PER・PBR・ROE・粗利益率・FCFなど)と
テクニカル指標(200日移動平均線・出来高など)でスクリーニングした中長期(3〜5年)保有候補銘柄。
Genie/ダッシュボード向けに整形済み(比率は%表記に変換済み)。
scoreはルールベースで算出した100点満点の総合スコア、rankはscore降順の順位、
ai_commentは上位30銘柄に付与された長期保有目線のAIコメント。'
""")
row_count = spark.table(VIEW).count()
print(f'✅ {VIEW} 作成完了: {row_count} 件')
手元の環境では ✅ stock_analysis.gold.v_candidates 作成完了: 32 件(22列)となりました。
件数はスクリーニングの通過数なので、実行時期によって変わります。
列コメントを CREATE VIEW の中に書く理由
見慣れない書き方かもしれません。テーブルなら
ALTER TABLE ... ALTER COLUMN ... COMMENT '...' で後付けできますが、ビューの列コメントは
後から追加・変更できません(ALTER VIEW に ALTER COLUMN 句がありません)。そのため、
CREATE VIEW の直後に列名と COMMENT を並べるインライン指定で与えています。
このコメントは、単なるドキュメントではありません。Genie が自然言語を SQL に翻訳する
ときに読む手がかりです。「配当利回りが高い銘柄」という質問を dividend_yield_pct に
結び付けられるのは、列名と、この日本語コメントがあるからです。第1部第4章では、
コメントを「資産カタログとして検索・共有できる文書」として紹介しました。生成 AI 時代のコメントには、
そこに機械に意味を教えるという役割が加わっています。
数値表現を揃える
SELECT 側では、次の3種類の処理をしています。
-
ROUNDで桁を落とす — 表示にも回答にも、小数点以下8桁は要りません -
* 100で % に統一 —roeなどの小数を % 表記に直し、列名を_pctにする。
dividend_yieldだけは yfinance の時点で既に % なので掛けません(列コメントにも
その旨を明記しています) -
COALESCE(ai_comment, '—')— AI コメントは上位30銘柄にしか付きません。NULL のまま
だと、表示側でも Genie の回答でも扱いが揺れるため、—という**「無い」ことを表す文字**
に寄せます
ORDER BY rank をビュー定義に入れているのは、SELECT * FROM v_candidates を素で叩いた
ときにスコア順で出てほしいからです。集計を挟めば当然この順序は保証されませんが、
「そのまま見ても意味が通る」状態にしておくと、Genie の回答もダッシュボードの表も
初期状態が整います。
テーブルコメントは「このデータは何か」の説明
COMMENT ON TABLE で、ビュー全体の説明も付けています。ここに書いているのは、
列を見ただけでは分からないことです。
- どの市場から、何を基準に絞った結果なのか
- 想定している保有期間(3〜5年)
-
scoreとrankとai_commentの関係
Genie はこの説明も読みます。「候補って何の候補?」に答えられるかどうかが、
的外れな SQL を防ぐ最初の防波堤になります。
Genie Agent を作る(GUI 手順)
ビューができたら、Space を作ります。ここは画面操作です。
- 左ナビゲーション「Genie」(または「+ New」→「Genie agent」)を開く
- 「Create」で Space 新規作成。Space 名の例:
東証スクリーニング候補 Genie - 「Add data」で対象テーブルとして
stock_analysis.gold.v_candidatesを選択 - 「SQL warehouse」欄でウェアハウスを選択(Free Edition では Serverless SQL ウェアハウスを指定)
- 「Instructions」タブに以下のセマンティック指示を貼り付ける:
このデータ(stock_analysis.gold.v_candidates)は、東証プライム・スタンダード・グロース市場の
上場銘柄から、ファンダメンタル指標(PER・PBR・ROE・粗利益率・FCFなど)とテクニカル指標
(200日移動平均線・出来高など)でスクリーニングした「中長期(3〜5年)保有候補」銘柄のみを含みます。
ダッシュボード表示・自然言語での問い合わせ向けに整形済みで、比率指標は小数ではなく%表記
(列名末尾が `_pct`)に変換されています。
- score: ルールベースで算出した100点満点の総合スコア(収益品質・成長性・バリュエーション・
財務健全性・長期トレンドの5カテゴリ)
- rank: score降順の順位
- ai_comment: 上位30銘柄に付与された、長期保有目線のAIコメント
- per, pbr, dividend_yield_pct, roe_pct, roa_pct, gross_margin_pct, fcf_margin_pct,
operating_margin_pct, revenue_growth_pct, earnings_growth_pct, debt_to_equity, current_ratio はすべて
ファンダメンタル指標であり、値が小さい/大きいほど良いかは指標により異なる点に注意すること。
- debt_to_equity: %換算値(100 が負債と自己資本が同額)。「D/E 比率が1以下」は100以下と読み替えること。
- momentum_12m_pct: 過去12ヶ月の株価騰落率(長期の指標)。短期モメンタム扱いせず、回答に使ってよい。
- MACD・RSIなどの短期モメンタム指標は本システムのスコアリングでは重視していないため、
回答で優先しないこと。
- 「Sample questions」に以下のサンプル質問を最低3件登録する:
- 「PERが15倍以下で配当利回りが高い銘柄を教えて」
- 「粗利益率が50%以上の銘柄を教えて」
- 「スコアが70点以上の銘柄をスコア順に見せて」
- 右上「Publish」で Space を公開する
- 公開後のブラウザURL(
.../genie/rooms/<SPACE_ID>)からSPACE_IDをコピーする
Instructions に何を書くか
Instructions は、Genie に渡す運用ルールです。列コメントが「各列が何か」を説明するのに
対し、こちらは「このデータをどう扱ってほしいか」を書きます。上の指示で効いているのは
次の3点です。
-
母集団の宣言 — 「スクリーニングを通過した候補のみを含む」。これを書かないと、
Genie は「東証の全銘柄が入っている」前提で答えかねません。データに入っていないものは
答えられないという限界を、最初に共有しておきます -
単位の宣言 — 「比率は % 表記(列名末尾
_pct)」。ビュー側で揃えたことを、言葉でも
重ねて伝えます -
優先順位の宣言 — 「MACD・RSI などの短期モメンタムは重視していない」。第3章で
スコアから短期指標を外した判断を、そのまま Genie にも引き継がせています
3つ目が、この Space に自分の投資方針を教え込んでいる部分です。汎用の Genie ではなく、
「長期保有の候補を見る道具」として振る舞わせるための一文になっています。
サンプル質問も同じ役割を持ちます。利用者にとっては入口のヒントですが、Genie にとっては
期待される問い方の実例です。
Space ID を設定する
ここからコードに戻ります。GUI で作った Space を、ID で掴みます。
from databricks.sdk import WorkspaceClient
SPACE_ID = '<あなたのGenie Agent ID>' # ★ 自分の環境の値に置き換える
w = WorkspaceClient()
space = w.genie.get_space(SPACE_ID)
print(f'Space title : {space.title}')
print(f'Description : {space.description}')
print(f'Warehouse ID: {space.warehouse_id}')
SPACE_ID は Publish 後の URL(.../genie/rooms/<SPACE_ID>)に出ている32桁の文字列です。
行末に ★ が付いているとおり、ここは自分の環境の値に置き換えてください。
出力はこうなります。
Space title : Japanese Stock Investment Analysis
Description : None
Warehouse ID: <あなたのウェアハウスID>
Space title には、手順 2 で付けた名前(例では 東証スクリーニング候補 Genie)が表示されます。上の出力は
筆者の Space の名前で、読者の出力とは異なります。
get_space() が通れば、ID が正しく、その Space にアクセスできることが確認できます。
ウェアハウス ID が返ってくることも大事です。Genie は SQL を実行するので、
ウェアハウスが紐づいていない Space は質問に答えられません。
自然言語で問い合わせて検証する
いよいよ本番です。3つの質問を投げて、妥当な SQL が生成され、結果が返るかを確かめます。
TEST_QUESTIONS = [
'PERが15倍以下で配当利回りが高い銘柄を教えて',
'粗利益率が50%以上の銘柄を教えて',
'スコアが70点以上の銘柄をスコア順に見せて',
]
results = []
for question in TEST_QUESTIONS:
print(f'--- 質問: {question} ---')
message = w.genie.start_conversation_and_wait(space_id=SPACE_ID, content=question)
sql_text = None
row_count = None
for att in message.attachments or []:
if att.text:
print(f'回答: {att.text.content}')
if att.query:
sql_text = att.query.query
print(f'生成SQL:\n{sql_text}')
qr = w.genie.get_message_attachment_query_result(
SPACE_ID, message.conversation_id, message.id, att.attachment_id
)
data = qr.statement_response.result.data_array if qr.statement_response.result else []
row_count = len(data) if data else 0
print(f'結果件数: {row_count} 件(先頭3件: {data[:3] if data else []})')
results.append({
'question': question,
'status': str(message.status),
'sql_generated': sql_text is not None,
'sql_references_view': sql_text is not None and 'v_candidates' in sql_text,
'row_count': row_count,
})
print()
1問目の出力は次のような形になります(生成 SQL・回答の文面・件数は実行のたびに変わります。
長いので一部を省いています)。
--- 質問: PERが15倍以下で配当利回りが高い銘柄を教えて ---
生成SQL:
SELECT `code`, `name`, `per`, `dividend_yield_pct` FROM `stock_analysis`.`gold`.`v_candidates` WHERE `per` <= 15 AND `dividend_yield_pct` IS NOT NULL ORDER BY `dividend_yield_pct` DESC
結果件数: 13 件(先頭3件: [['2121', 'MIXI', '11.5', '5.07'], ['6592', 'マブチモーター', '14.4', '3.96'], ['4633', 'サカタインクス', '10.6', '3.81']])
回答: PERが15倍以下で配当利回りが高い銘柄は13件ありました。…
生成された SQL は v_candidates の dividend_yield_pct(列名の _pct と日本語コメントが手がかり)を
参照しています。status は MessageStatus.COMPLETED のように表示されます。
Genie の回答は「添付」で返ってくる
このコードの読みどころは、回答の構造です。start_conversation_and_wait() は会話を開始
して完了まで待ち、message を返します。答えは message.attachments という添付のリストに
入っていて、種類が2つあります。
-
att.text— 自然文の回答(「以下の3銘柄が該当します」といった説明) -
att.query— 生成された SQL。att.query.queryに SQL 文字列が入る
そしてデータそのものは message に入っていません。SQL の実行結果は
get_message_attachment_query_result() で、会話 ID・メッセージ ID・添付 ID を指定して
別途取りに行きます。「テキスト」「SQL」「結果」が分かれているのが Genie API の形です。
_and_wait が付いたメソッドを使っているのは、Genie の応答が非同期だからです。
待たないバージョンでは、ポーリングして完了を待つコードを自分で書く必要があります。
何をもって「動いた」とするか
ループの最後で、質問ごとに4つの情報を記録しています。このうち、あとの完了確認で
assert して合否に使うのは sql_generated と sql_references_view の2つです。
status と row_count は出力に印字するだけで、人間が目で確かめます。
| 記録している値 | 確かめたいこと | 判定 |
|---|---|---|
status |
会話が最後まで完了したか(COMPLETED) |
目視 |
sql_generated |
そもそも SQL が生成されたか | assert |
sql_references_view |
生成 SQL が v_candidates を参照しているか |
assert |
row_count |
結果が返ってきたか | 目視 |
sql_references_view が、この検証の肝です。「答えが返ってきた」だけでは合格にしません。生成された
SQL が、意図したコメント付きビューを見ているかどうかまで確認します。この Space に登録して
あるのは v_candidates だけなので、screening_results を直接叩く事態は起こりにくい構成です。
それでも見ておくのは、後から別のテーブルを足したときの回帰検知になるからです。もし生成 SQL が
screening_results を直接叩いていたら、たとえ答えが正しく見えても、この Space の設計は
崩れています。生成 AI を挟む処理では、出力ではなく経路を検証することが効きます
(ただし判定は v_candidates という文字列を含むかどうかの簡易なものです)。
完了確認
最後に、ビューと Space の両方をまとめて検証します。
print('=== 完了確認 ===')
assert spark.catalog.tableExists(VIEW), f'{VIEW} が存在しない'
expected_cols = {
'rank', 'code', 'name', 'market_segment', 'industry_33', 'current_price', 'per', 'pbr',
'dividend_yield_pct', 'roe_pct', 'roa_pct', 'gross_margin_pct', 'fcf_margin_pct',
'revenue_growth_pct', 'earnings_growth_pct', 'operating_margin_pct', 'debt_to_equity',
'current_ratio', 'momentum_12m_pct', 'score', 'ai_comment', 'screened_date',
}
actual_cols = set(spark.table(VIEW).columns)
assert expected_cols <= actual_cols, f'不足カラム: {expected_cols - actual_cols}'
print(f'✅ {VIEW}: {len(actual_cols)} 列、{spark.table(VIEW).count()} 件')
assert SPACE_ID, 'SPACE_ID が未設定(GUIで作成したSpaceのIDを貼り付けること)'
assert space.warehouse_id, 'Spaceにウェアハウスが紐づいていない'
for r in results:
print(f"[{r['status']}] SQL生成={r['sql_generated']} v_candidates参照={r['sql_references_view']} "
f"結果件数={r['row_count']} 質問={r['question']}")
assert r['sql_generated'], f"質問「{r['question']}」でSQLが生成されなかった"
assert r['sql_references_view'], f"質問「{r['question']}」の生成SQLがv_candidatesを参照していない"
print('\n✅ v_candidates ビューが作成され、Genie Agentがそれを参照して動作していることを確認')
print('✅ 自然言語クエリ 3パターンすべてでSQLが生成され、v_candidatesを参照していることを確認(status・結果件数は上の出力で目視確認)')
print('\n--- 手動確認チェック(GUI側、コードでは検証不可) ---')
print('[ ] 「Genie Agent を作る」の手順どおりInstructionsを設定した')
print('[ ] 「Genie Agent を作る」の手順どおりSample questionsを3件以上登録した')
print('\n次のステップ → LLMOps独自エージェント編 / ダッシュボード(v_candidatesを再利用)')
筆者の環境での完了確認は、質問ごとに次のように出力されました(結果件数は 13 / 9 / 11)。
[MessageStatus.COMPLETED] SQL生成=True v_candidates参照=True 結果件数=13 質問=PERが15倍以下で配当利回りが高い銘柄を教えて
列の検証で expected_cols <= actual_cols(部分集合)を使っているのは、列が増えることは
許すが、減ることは許さないという意思表示です。後からビューに列を足しても、この
チェックは壊れません。逆に、期待した列が消えていれば 不足カラム: {...} と名前が出ます。
最後の2行は、コードで検証できないことを、あえて印字しています。Instructions と
サンプル質問が GUI に入っているかは、API からは確認しづらい部分です。ここを黙って
飛ばすのではなく、[ ] のチェックボックスとして出力に残す。自動で確かめたことと、
人間が目で確かめるべきことを、同じ場所に並べて見せるための工夫です。
筆者の環境では、v_candidates(22列・32件。件数は実行時期で変わります)に対して3つの質問がいずれも COMPLETED で
返り、生成 SQL はすべて v_candidates を参照していました。
コラム:Genie でできること、できないこと
ここまでで、SQL を書かずにスクリーニング結果へ問いかけられるようになりました。
この手軽さは本物です。ビューを1つ整えて、画面で指示を書いて、公開する。それだけで
「自然言語で聞ける分析環境」が立ち上がります。
一方で、Genie には構造的な限界もあります。
-
自由に SQL を書く — 強みであり弱みでもあります。想定外の集計もできる代わりに、
こちらが望まないテーブルの読み方をされる余地が残ります -
評価の自由度が低い — Genie にも、正解 SQL を登録して回答を採点するベンチマーク機能
(API は Beta)があります。ただし、1回ごとの処理の経路をトレースして追ったり、
「根拠のない断定をしていないか」のような自分で決めた観点を LLM-as-a-judge で採点したり
といったことはできません。今回のように質問を投げてassertする検証は、自前で書くことになります -
振る舞いを細かく制御できない — Instructions は自然文の指示なので、「必ず免責を
付ける」「一覧には最低1つリスクを添える」といった要件を保証はできません
つまり Genie は、探索には最適だが、品質を作り込むには物足りないのです。
そこで次章では、同じデータに対して自分でエージェントを実装します。ツールを
search_candidates / get_candidate_detail の2つに絞り、応答をトレースし、
LLM-as-a-judge で評価する——Genie では届かない領域に手を入れていきます。
まとめ
- Genie に見せるのは生テーブルではなくコメント付きビュー
v_candidates。内部処理用の列や
結合の副産物は、Genie を迷わせるので最初から視界に入れない - 精度を上げる作業の大半はデータ側の整形。列を22に絞り、比率を % に統一(
_pct)し、
丸め、COALESCEで NULL を潰す -
列コメントは Genie が読む手がかり。ビューは列コメントを後付けできないので
CREATE VIEWに
インラインで書く。COMMENT ON TABLEには「このデータは何か」を書く -
Instructions には運用ルールを書く。母集団の宣言・単位の宣言・優先順位の宣言(短期
モメンタムは重視しない=第3章の配点判断をそのまま引き継ぐ) - Genie の回答は
attachmentsに分かれて返る(text/query)。結果データは別 API
(get_message_attachment_query_result())で取りに行く - 検証は「答えが返った」で終わらせず、生成 SQL が
v_candidatesを参照しているかまで見る。
生成 AI は出力ではなく経路を確かめる - コードで確認できないこと(GUI の設定)は、チェックボックスとして出力に残す
次章(LLMOps 独自エージェント)へ: Genie は探索に強く、作り込みには向きません。
次章では同じv_candidatesを相手に、限定されたツールを持つエージェントを自分で実装し、
MLflow でトレースを取り、LLM-as-a-judge で評価します。第3章のコラムで見た
「AI の出力が正しいかを、毎回人間が読む以外の方法で確かめられないのか」という問いに、
ここで答えを出します。
第6章 LLMOps 独自エージェント — 作って、測って、公開する
前章の Genie は、ビューを1つ整えて画面で指示を書くだけで「自然言語で聞ける分析環境」が
立ち上がりました。手軽さは本物です。しかし章末で書いたとおり、Genie には
品質を継続的に測る仕組みがなく、振る舞いを保証できないという限界があります。
この章では、同じ gold.v_candidates を相手に、自分でエージェントを実装します。
Genie が「自然言語 → 自由な SQL」なのに対し、こちらは 限定されたツール2つしか持たせません。
そのうえで MLflow でトレースを取り、LLM-as-a-judge で評価し、最後は Model Serving で
チャットとして公開します。第3章のコラムで置いた問い——「AI の出力が正しいかを、毎回
人間が読む以外の方法で確かめられないのか」——に、ここで答えを出します。
この章のノートブック
ファイル 役割 agents/__init__.pyパッケージ初期化 agents/screening_agent.pyエージェント本体(LangGraph)+限定ツール notebooks/06a_llmops_agent.pyトレース生成・スコアラー定義・自動評価 notebooks/06b_prompt_registry.pyシステムプロンプトを Unity Catalog で管理 agents/chat_agent.pyModel Serving 用の ChatAgent ラッパー notebooks/06c_deploy_agent.pyモデルのログとデプロイ
agents/の3ファイルは、ノートブックからimportして使う Python モジュールです。カタログ規約: 参照は
stock_analysis.gold.v_candidates。新規作成は
stock_analysis.prompts(プロンプト)・stock_analysis.agents(エージェント資産)
章は4ステップ構成です。①エージェントを作る → ②測る → ③プロンプトを管理する → ④公開する。
LLMOps という言葉が指す一連の流れを、この順でひととおり通します。
ステップ1:エージェントを作る
なぜノートブックに直接書かないのか
エージェント本体は、ノートブックではなく agents/screening_agent.py という Python
モジュールとして実装します。理由は単純で、同じコードを3か所から使うからです。
- 評価ノートブック(
06a)から呼ぶ - Prompt Registry ノートブック(
06b)がSYSTEM_PROMPTを読む - Model Serving のコンテナの中で動く
ノートブックのセルに書いてしまうと、この使い回しができません。Databricks では
ノートブックと同じ階層に Workspace Files として .py を置けるので、普通の Python
モジュールとして import できます。
agents/__init__.py は、パッケージとして使うときの入口です。
"""東証スクリーニング LLMOps 独自エージェント。
`screening_agent` モジュールに LangGraph ベースのエージェント本体と、
`gold.v_candidates` を参照する限定ツールを実装している。
"""
from .screening_agent import (
ScreeningAgent,
Thread,
SYSTEM_PROMPT,
search_candidates,
get_candidate_detail,
)
__all__ = [
"ScreeningAgent",
"Thread",
"SYSTEM_PROMPT",
"search_candidates",
"get_candidate_detail",
]
エージェント本体(agents/screening_agent.py)
モジュールの入口と設定
"""東証スクリーニング結果に回答する LLMOps 独自エージェント(LangGraph)。
Genie(自然言語→自由SQL)に対し、本エージェントは
`stock_analysis.gold.v_candidates` を参照する **限定ツール** だけを持ち、
長期保有(3〜5年)目線の投資所見を生成する。ツールをパラメータ化することで
誤った SQL 生成を防ぎ、MLflow トレーシング+LLM-as-a-judge 評価で品質を
継続検証できる状態にすることが狙い(=Genie では作り込めない "作り込み")。
## モジュールの使い方(notebook 側)
import sys, os
sys.path.append(os.path.abspath("../agents"))
from screening_agent import ScreeningAgent, Thread
agent = ScreeningAgent()
answer = agent.process_query("スコア70点以上で配当利回りが高い銘柄は?", Thread())
experiment の指定は呼び出し側 notebook に委ねる(本モジュールは
`mlflow.langchain.autolog()` を有効化するだけで記録先は決め打ちしない。
import 時に記録先を固定すると評価用の experiment と衝突するため)。
"""
from __future__ import annotations
import contextvars
import os
import uuid
from contextlib import contextmanager
from dataclasses import dataclass, field
from typing import List, Optional
import mlflow
import pandas as pd
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage
from langchain_core.tools import tool
from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import END, StateGraph
from langgraph.graph.message import MessagesState
from langgraph.prebuilt import ToolNode
from databricks_langchain import ChatDatabricks
# MLflowトレーシングを有効化(experimentの指定は呼び出し側notebookに委ねる)
mlflow.langchain.autolog()
# ── 設定(notebook 側から環境変数で差し替え可能)───────────────────────────
# 本体LLM: ツール呼び出しの信頼性のため 70b を既定にする。
# Free Edition 実測(2026-07-21): `databricks-meta-llama-3-1-70b-instruct` は存在せず、
# `databricks-meta-llama-3-3-70b-instruct` が実在しツール呼び出しも可。
LLM_ENDPOINT = os.environ.get("LLM_ENDPOINT", "databricks-meta-llama-3-3-70b-instruct")
# 参照する整形済みビュー(Genie と同じビューを共有する)。
CANDIDATES_TABLE = os.environ.get("CANDIDATES_TABLE", "stock_analysis.gold.v_candidates")
# Spark が無い環境(Model Serving コンテナ)で SQL 経由取得に使う SQL ウェアハウス。
# Free Edition の Serverless Starter Warehouse。notebook 側から差し替え可能。
WAREHOUSE_ID = os.environ.get("WAREHOUSE_ID", "<あなたのウェアハウスID>") # ★ 自分の環境の値に置き換える
冒頭の1行、mlflow.langchain.autolog() がトレーシングのスイッチです。これだけで、
LangChain / LangGraph の呼び出し(LLM への問い合わせ、ツール実行、その入出力と所要時間)が
すべて MLflow に記録されるようになります。第1部第5章で @mlflow.trace を手で付けたのに対し、
こちらはフレームワーク単位の自動計装です。
注目してほしいのは、記録先の experiment をここで決めていないことです。モジュールが
import 時に記録先を固定してしまうと、呼び出し側が別の experiment を使いたいとき(評価用と
開発用を分けたいときなど)に衝突します。「どう記録するか」はモジュール、「どこへ記録
するか」は呼び出し側、という分担にしています。
設定値がすべて os.environ.get(...) になっているのも意図的です。ノートブックからも、
サービングのコンテナからも、環境変数で差し替えられる必要があるためです。
データ取得は3経路用意する
ここが、このモジュールでいちばん実務的な部分です。
# ── ツールが参照する候補データ(40件程度のため pandas 化してキャッシュ)────────
# ノートブック等、Spark があり実行者が1人の環境で使うモジュールレベルのキャッシュ。
_candidates_df = None
# ── リクエストスコープ ─────────────────────────────────────────────────────
# サービング時は、データ取得に使う WorkspaceClient をリクエストごとに組み立てて
# ここへ載せる。キャッシュもリクエスト単位にする。狙いは2つ。
# 1. 資格情報の作り方を1か所(chat_agent 側)に閉じ込め、ツールは受け取るだけにする
# 2. 週次バッチで v_candidates が更新されたとき、コンテナを再起動しなくても
# 次のリクエストから最新を読む(モジュールにキャッシュすると古いまま居座る)
_request_state: contextvars.ContextVar[Optional[dict]] = contextvars.ContextVar(
"screening_agent_request_state", default=None
)
@contextmanager
def request_scope(workspace_client):
"""1リクエストの間だけ、指定した WorkspaceClient を有効にする。
`chat_agent.ScreeningChatAgent.predict()` から使う。スコープを抜けると
クライアントもキャッシュも破棄される。
Args:
workspace_client: データ取得に使う認証済みの WorkspaceClient。
"""
token = _request_state.set({"client": workspace_client, "df": None})
try:
yield
finally:
_request_state.reset(token)
# 数値として扱う SQL 型(SQLウェアハウス経由取得時の型変換に使う)
_NUMERIC_SQL_TYPES = {
"INT", "INTEGER", "SHORT", "BYTE", "LONG", "BIGINT",
"FLOAT", "DOUBLE", "DECIMAL",
}
def _get_active_spark():
"""アクティブな SparkSession を返す(無ければ None)。
pyspark 自体が無いサービング環境でも import で落ちないよう遅延 import する。
"""
try:
from pyspark.sql import SparkSession
return SparkSession.getActiveSession()
except Exception:
return None
def _load_via_sql_warehouse(workspace_client=None) -> "pd.DataFrame":
"""SQL ウェアハウス経由で候補ビューを pandas DataFrame として取得する。
Model Serving のコンテナには Spark クラスタが無いため、`spark.table()` の代わりに
Databricks SDK の Statement Execution API で `SELECT` する。この API を叩くには
ワークスペースの `databricks-sql-access` エンタイトルメントとウェアハウスの `CAN_USE`
が要るため、サービング時は両方を付与した専用サービスプリンシパルのクライアントを渡す。
Args:
workspace_client: 使用する WorkspaceClient。サービング時は専用サービスプリンシパルで
組み立てたクライアントを渡す。省略時は既定の認証で組み立てる
(ノートブックからの単体検証用)。
"""
if workspace_client is None:
from databricks.sdk import WorkspaceClient
workspace_client = WorkspaceClient()
resp = workspace_client.statement_execution.execute_statement(
warehouse_id=WAREHOUSE_ID,
statement=f"SELECT * FROM {CANDIDATES_TABLE}",
wait_timeout="50s",
)
state = getattr(resp.status, "state", None)
if state is None or str(getattr(state, "value", state)) != "SUCCEEDED":
raise RuntimeError(f"候補ビューの取得に失敗しました(state={state}): {CANDIDATES_TABLE}")
columns = resp.manifest.schema.columns
names = [c.name for c in columns]
numeric = {
c.name
for c in columns
if str(getattr(c.type_name, "value", c.type_name)).upper() in _NUMERIC_SQL_TYPES
}
rows = (resp.result.data_array if resp.result else None) or []
df = pd.DataFrame(rows, columns=names)
for col in names:
if col in numeric:
df[col] = pd.to_numeric(df[col], errors="coerce")
return df
def _load_candidates() -> "pd.DataFrame":
"""`v_candidates` を pandas DataFrame として取得する。
候補は数十件と小さいため、都度クエリせず pandas に載せてツール内でフィルタする。
取得経路とキャッシュの寿命は実行環境で変わる。
- **サービング**: `request_scope()` の中で呼ばれる。渡されたクライアント
(専用サービスプリンシパル)で SQL ウェアハウスから読み、そのリクエストの間だけ
キャッシュする。
- **ノートブック(Spark あり)**: `spark.table(...)` で読み、モジュールレベルに
キャッシュする。最新に更新したい場合は `reset_candidates_cache()` を呼ぶ。
- **Spark も リクエストスコープも無い**: 既定の認証で SQL ウェアハウスから読む
(モジュール単体の動作確認用)。
"""
state = _request_state.get()
if state is not None:
# リクエストスコープ経路。キャッシュはこのリクエスト限り。
if state["df"] is None:
state["df"] = _load_via_sql_warehouse(state["client"])
return state["df"]
global _candidates_df
if _candidates_df is None:
spark = _get_active_spark()
if spark is not None:
_candidates_df = spark.table(CANDIDATES_TABLE).toPandas()
else:
_candidates_df = _load_via_sql_warehouse()
return _candidates_df
def reset_candidates_cache() -> None:
"""モジュールレベルのキャッシュを破棄する(データ更新後に最新を読み直すため)。
リクエストスコープ側のキャッシュはリクエストごとに作られて捨てられるため、
ここでの操作は不要。
"""
global _candidates_df
_candidates_df = None
Model Serving のコンテナには Spark がありません。 ノートブックで動いていた
spark.table(...) は、デプロイした瞬間に動かなくなります。これは実際に踏んだ落とし穴で、
本章の後半(サービング)へ進むために避けて通れない部分でした。
そこで _load_candidates() は、取得経路を3つ用意し、次の優先順位で選びます。
-
リクエストスコープの中(サービング時)。
request_scope()で渡されたWorkspaceClient
(専用のサービスプリンシパルで組み立てたもの)を使って SQL ウェアハウスから読み、
そのリクエストの間だけキャッシュします -
アクティブな SparkSession がある(ノートブック)。
spark.table()で読み、
モジュールにキャッシュします - どちらも無い。既定の認証で SQL ウェアハウスから読みます(モジュール単体の動作確認用)
1 の「リクエストスコープ」は、contextvars.ContextVar で作っています。今処理している
リクエストだけに見える入れ物で、並行する別のリクエストとは混ざりません。サービング側が
「このリクエストはこの資格情報で読む」と入れ、ツールは取り出して使うだけです。こうすると、
資格情報の作り方が1か所(後述の chat_agent.py)に閉じます。週次バッチで v_candidates が
更新されたときも、コンテナを再起動せずに次のリクエストから最新を読めます(モジュールに
キャッシュすると、古いまま居座ってしまうためです)。
_get_active_spark() が pyspark の import ごと try で包んでいるのは、pyspark が
入っていない環境でも、モジュールの import 自体は成功させるためです。
SQL ウェアハウス経由の _load_via_sql_warehouse() には、もうひとつ地味な仕事があります。
Statement Execution API の戻り値は、数値も文字列で返ってくるのです。そのまま pandas に
載せると df["score"] >= 70 が文字列比較になり、フィルタが壊れます。そこでマニフェストの
型情報から数値列を判定し、pd.to_numeric() で変換しています。同じツールが、Spark 経由でも SQL ウェアハウス経由でも
同じ振る舞いをするように揃えるのが、ここでの目的です。
候補は数十件しかないので、都度クエリせず pandas に載せてキャッシュし、フィルタは
Python 側で行います。ノートブック側のモジュールキャッシュは、データが更新されたら
reset_candidates_cache() で破棄します(リクエストスコープのキャッシュはリクエストごとに
捨てられるので、この操作は要りません)。
ツールは2つだけ
Genie との最大の違いがここです。エージェントに与えるのは、パラメータが決まった2つの
関数だけ。自由な SQL は書かせません。
# ── ツール定義(Genie との差別化=限定・パラメータ化されたツール)──────────────
@tool
def search_candidates(
min_score: int = 0,
industry: str = "",
max_per: float = 0.0,
min_dividend_yield: float = 0.0,
limit: int = 10,
) -> str:
"""スクリーニング通過銘柄を条件で検索する。
長期保有候補の一覧を、スコア・業種・バリュエーション・配当で絞り込む。
条件を満たす銘柄をスコア降順で返す。
Args:
min_score: 最低スコア(0〜100)。0 なら下限なし。
industry: 業種名の部分一致(東証33業種、例「情報・通信」「医薬品」)。空なら全業種。
max_per: PER の上限。0 以下なら上限なし。
min_dividend_yield: 配当利回り(%)の下限。0 以下なら下限なし。
limit: 返す最大件数。
Returns:
条件に合致した銘柄の要約テキスト(スコア降順)。
"""
df = _load_candidates().copy()
if min_score:
df = df[df["score"] >= min_score]
if industry:
# 業種は東証33業種の正規名(例「機械」)で保持しているが、モデルは
# 「機械セクター」「機械業種」のように渡すことがある。サフィックスを落とし
# 双方向に部分一致させて、正規名との表記揺れを吸収する(片方向 contains だと
# 「機械」を含む "機械セクター" を渡されたとき正規名「機械」に当たらず0件になる)。
key = industry.strip()
for _suf in ("セクター", "セクタ", "業種", "業界"):
if key.endswith(_suf) and len(key) > len(_suf):
key = key[: -len(_suf)]
break
ind = df["industry_33"].astype(str)
df = df[ind.str.contains(key, na=False) | ind.apply(lambda v: bool(v) and v in key)]
if max_per and max_per > 0:
df = df[df["per"].notna() & (df["per"] <= max_per)]
if min_dividend_yield and min_dividend_yield > 0:
df = df[df["dividend_yield_pct"].notna() & (df["dividend_yield_pct"] >= min_dividend_yield)]
df = df.sort_values("score", ascending=False).head(int(limit) if limit else 10)
if df.empty:
return "条件に合致するスクリーニング通過銘柄はありませんでした。"
lines = [f"該当 {len(df)} 件(スコア降順):"]
for _, r in df.iterrows():
lines.append(
f"- {r['name']}({r['code']} / {r['industry_33']}): "
f"スコア{_fmt(r['score'], 0)} / PER{_fmt(r['per'], 1)} / PBR{_fmt(r['pbr'], 2)} / "
f"配当{_fmt(r['dividend_yield_pct'], 2)}% / ROE{_fmt(r['roe_pct'], 1)}% / "
f"粗利益率{_fmt(r['gross_margin_pct'], 1)}% / 12ヶ月{_fmt(r['momentum_12m_pct'], 1)}%"
)
return "\n".join(lines)
docstring がそのまま仕様書になっている点に注目してください。@tool デコレータは、
関数のシグネチャと docstring を読んで、LLM に渡すツール定義を組み立てます。つまり
Args: に書いた説明は、LLM がパラメータの意味を理解するための文章です。
「0 なら下限なし」まで書いてあるのは、モデルに省略の仕方を教えるためです。
途中の業種フィルタは、実際にエージェントを動かして見つかった問題への対処です。
ビューは東証33業種の正規名(「機械」)で持っていますが、モデルは 「機械セクター」
のような言い方でパラメータを渡してきます。素直に str.contains("機械セクター") を
すると正規名「機械」に当たらず、0件になってしまう。しかも毎回そう渡すわけではないので、
運次第で0件という最悪の不具合になります。そこでサフィックスを落とし、さらに
双方向(列が引数を含む/引数が列を含む)で照合しています。
もう1つのツールは、1銘柄を掘り下げるためのものです。
@tool
def get_candidate_detail(code: str) -> str:
"""指定した証券コードの銘柄について、全指標と既存AIコメントを取得する。
1銘柄の長期評価を掘り下げるときに使う。収益品質・成長性・バリュエーション・
財務健全性・長期トレンドの各指標と、スコアリング時に生成済みのAIコメントを返す。
Args:
code: 証券コード(例「7203」)。
Returns:
銘柄の詳細指標テキスト。通過銘柄でない場合はその旨を返す。
"""
df = _load_candidates()
row = df[df["code"].astype(str) == str(code).strip()]
if row.empty:
return (
f"証券コード {code} はスクリーニング通過銘柄に含まれていません"
"(条件を満たさなかったか、対象外の銘柄です)。"
)
r = row.iloc[0]
return (
f"【{r['name']}({r['code']} / {r['industry_33']} / {r['market_segment']})】\n"
f"総合スコア: {_fmt(r['score'], 0)}/100(ランク {_fmt(r['rank'], 0)})\n"
f"株価: {_fmt(r['current_price'], 0)}円\n"
f"[収益品質] 粗利益率 {_fmt(r['gross_margin_pct'], 1)}% / "
f"FCFマージン {_fmt(r['fcf_margin_pct'], 1)}% / ROA {_fmt(r['roa_pct'], 1)}%\n"
f"[成長性] 売上成長率 {_fmt(r['revenue_growth_pct'], 1)}% / "
f"利益成長率 {_fmt(r['earnings_growth_pct'], 1)}%\n"
f"[バリュエーション] ROE {_fmt(r['roe_pct'], 1)}% / PER {_fmt(r['per'], 1)} / "
f"PBR {_fmt(r['pbr'], 2)} / 配当利回り {_fmt(r['dividend_yield_pct'], 2)}%\n"
f"[財務健全性] D/E {_fmt(r['debt_to_equity'], 0)} / 流動比率 {_fmt(r['current_ratio'], 1)}\n"
f"[長期トレンド] 12ヶ月モメンタム {_fmt(r['momentum_12m_pct'], 1)}%\n"
f"[営業利益率] {_fmt(r['operating_margin_pct'], 1)}%\n"
f"[既存AIコメント] {r.get('ai_comment', '—')}"
)
def _fmt(v, digits: int) -> str:
"""None/NaN を安全に整形するヘルパー。"""
try:
if v is None:
return "N/A"
f = float(v)
if f != f: # NaN
return "N/A"
return f"{f:.{digits}f}"
except (TypeError, ValueError):
return str(v)
TOOLS = [search_candidates, get_candidate_detail]
該当がないときに空を返さず、「含まれていません」という文章を返すのが要点です。
ツールが沈黙すると、LLM は空白を埋めようとして作り話を始めます。「無い」という事実を
言葉で返すことが、そのまま幻覚対策になります。
_fmt() は NaN を N/A に潰すだけのヘルパーですが、これも同じ趣旨です。nan という
見慣れない値を渡すと、モデルはそれらしい数値を補ってしまうことがあります。
システムプロンプトは「実測の履歴」
次はシステムプロンプトです。長いですが、一行ずつに事故の記憶があるという読み方を
してください。
# ── システムプロンプト ─────────────────────────────────────────────────────
SYSTEM_PROMPT = """あなたは東証上場株の中長期投資候補を分析するアシスタントです。
利用者は3〜5年の長期保有を前提に、スクリーニングを通過した候補銘柄について質問します。
あなたの責務:
- 提供されたツールを使ってスクリーニング通過銘柄の情報を取得し、事実に基づいて回答する
- 一時的な株価変動ではなく、ビジネスモデルの持続性・競争優位性・財務健全性を重視する
必ず守ること:
- 数値や銘柄の情報は、必ずツールから取得した結果に基づいて述べる(推測で数値を作らない)
- **ツールの結果に登場しない企業名・製品名・事業内容を持ち出さない**。例え話や補足であっても、
ツールから得ていない固有名詞(実在・架空を問わず)を創作して論評してはならない
- スクリーニング通過銘柄に含まれない銘柄について問われたら、その旨を正直に伝える
- **ユーザーが明示した条件だけをツールに渡す**。ユーザーが指定していないスコア・PER・配当利回り・業種等の
絞り込みを勝手に追加しない(「機械の候補は?」に対して勝手に「スコア60以上・配当2%以上」等を付け足さない)
- 実際にツールへ渡して絞り込んだ条件だけを述べる。絞っていない指標を「〜という条件で選ばれています」
「PER・ROE等も一定水準を維持」等と、さも絞り込んだかのように装わない
- **「最も〜」「一番〜」等の比較・序列は、ツール結果に含まれる数値だけで判定する**。手元の一覧で
比較できるなら必ず答える(「情報を提供できない」と逃げない)。必要なら追加でツールを呼んで値を揃える
- **リスク要因への言及を必ず含める**。1銘柄を深掘りする場合はその銘柄の主なリスク
(業種サイクル・債務・競合・成長鈍化など)を述べる。複数銘柄を一覧で答える場合も、
回答の末尾に「留意点」として長期保有で共通して注意すべきリスクを最低1文添える。
- 回答の最後に「本内容は投資判断の参考情報であり、投資助言・推奨ではありません。」という
免責の一文を必ず添える
"""
太字で強調されている項目は、すべて実際の失敗を受けて後から追加された制約です。
| 制約 | 追加のきっかけ |
|---|---|
| ツール結果にない固有名詞を出さない | 一覧そのものは正しいのに、論評部分で実在しない企業を創作した |
| 指定された条件だけを渡す | 「機械の候補は?」に勝手な絞り込みを足し、8件を4件に減らした |
| 序列はツール結果の数値だけで判定・逃げない | 手元の一覧で比較できるのに「情報を提供できない」と答えた/最高 ROE の銘柄を誤答した |
| リスク要因に必ず言及 | 一覧回答のときだけリスクに触れず、後述の評価で mentions_risk が 0 になった |
つまりこのプロンプトは、設計図ではなくログです。最初から全部書けたわけではなく、
動かし、評価し、崩れた箇所を1つずつ埋めた結果がこの形になっています。LLMOps とは
この往復のことだと考えると、この章全体の見通しが良くなります。
会話スレッドとエージェント本体
# ── 会話スレッド(マルチターン用の軽量な入れ物)─────────────────────────────
@dataclass
class Thread:
"""会話スレッド。`id` は LangGraph の checkpointer のキーに使う。
実際の会話履歴は MemorySaver が `thread_id` ごとに保持するため、
`messages` は「システムプロンプトを既に注入したか」の判定用の軽い記録。
"""
id: str = field(default_factory=lambda: str(uuid.uuid4()))
messages: List[dict] = field(default_factory=list)
会話履歴そのものは LangGraph の MemorySaver が持つので、Thread は ID の入れ物に
すぎません。messages を持っているのは「このスレッドにシステムプロンプトを注入済みか」を
判定するためだけです。
# ── エージェント本体 ───────────────────────────────────────────────────────
class ScreeningAgent:
"""スクリーニング結果に回答する LangGraph エージェント。
`process_query(query, thread)` を統一インターフェースとして提供する
(呼び出し側は内部のフレームワークに依存しない)。
"""
def __init__(self, system_prompt: Optional[str] = None):
"""エージェントを初期化する。
Args:
system_prompt: 使用するシステムプロンプト。省略時はモジュール定数の
SYSTEM_PROMPT を使う(将来 Prompt Registry から差し込めるようにする注入口)。
"""
self.system_prompt = system_prompt or SYSTEM_PROMPT
self.tools = TOOLS
self.executor = self._build_graph()
print(f"ScreeningAgent を初期化しました(ツール数: {len(self.tools)}):")
for t in self.tools:
print(f" - {t.name}")
def _tools_condition(self, messages: List[BaseMessage]) -> str:
"""最後のAIメッセージにツール呼び出しがあれば 'tools'、なければ 'end'。"""
if messages:
last = messages[-1]
if isinstance(last, AIMessage) and getattr(last, "tool_calls", []):
return "tools"
return "end"
def _build_graph(self):
"""agent ⇄ tools の LangGraph を構築してコンパイルする。"""
model = ChatDatabricks(endpoint=LLM_ENDPOINT)
model_with_tools = model.bind_tools(self.tools)
def call_model(state: MessagesState):
response = model_with_tools.invoke(state["messages"])
return {"messages": [response]}
graph = StateGraph(MessagesState)
graph.add_node("agent", call_model)
graph.add_node("tools", ToolNode(self.tools))
graph.add_conditional_edges(
"agent",
lambda state: self._tools_condition(state["messages"]),
{"tools": "tools", "end": END},
)
graph.add_edge("tools", "agent")
graph.set_entry_point("agent")
# MemorySaver で thread_id ごとに会話履歴を保持(マルチターン対応)
return graph.compile(checkpointer=MemorySaver())
def _extract_last_ai_message(self, messages) -> Optional[AIMessage]:
for msg in reversed(messages):
if isinstance(msg, AIMessage):
return msg
return None
def process_query(self, query: str, thread: Thread) -> str:
"""ユーザーのクエリをエージェントで処理して回答文字列を返す。
Args:
query: ユーザーの入力メッセージ
thread: コンテキストを維持するための会話スレッド
Returns:
エージェントの回答(文字列)
"""
incoming_messages: List[BaseMessage] = []
# 新しい会話の場合のみシステムプロンプトを先頭に注入する
if not thread.messages:
incoming_messages.append(SystemMessage(content=self.system_prompt))
thread.messages.append({"role": "system", "content": self.system_prompt})
incoming_messages.append(HumanMessage(content=query))
thread.messages.append({"role": "user", "content": query})
config = {"configurable": {"thread_id": thread.id}}
result_state = self.executor.invoke({"messages": incoming_messages}, config=config)
ai_message = self._extract_last_ai_message(result_state["messages"])
if ai_message is None:
raise RuntimeError("エージェントからの回答がありませんでした。")
thread.messages.append({"role": "assistant", "content": ai_message.content})
return ai_message.content
グラフの形は、2つのノードのループというシンプルなものです。
┌─────────┐ ツール呼び出しあり ┌────────┐
start →│ agent │ ───────────────────→ │ tools │
│ (LLM) │ ←─────────────────── │ │
└─────────┘ └────────┘
│ ツール呼び出しなし
↓
END
agent ノードで LLM に問い合わせ、返答に ツール呼び出しが含まれていれば tools ノードへ、
含まれていなければ終了。tools を通ったら必ず agent に戻るので、モデルは
「ツールの結果を見てもう一度考える」ことができます。1回の質問で search_candidates を
呼んでから、さらに get_candidate_detail を呼ぶ、といった多段の動きもこの形で自然に出ます。
分岐を決めているのが _tools_condition() です。最後の AI メッセージに tool_calls が
あるかどうかだけを見ます。
compile(checkpointer=MemorySaver()) がマルチターン対応の部分です。thread_id ごとに
状態を保持するので、process_query() に同じ Thread を渡し続ければ会話が続きます。
だからシステムプロンプトの注入もスレッドの最初の1回だけでよいわけです。
system_prompt を引数で受け取れるようにしてあるのは、後半の Prompt Registry で
Unity Catalog に登録したプロンプトを外から差し込むための注入口です。
ステップ2:測る(notebooks/06a_llmops_agent.py)
エージェントができました。ここからが本題です。動いているように見えるものを、
どうやって「ちゃんと動いている」と言えるようにするか。
準備
%pip install -qU \
"mlflow[databricks]>=3.9.0,<3.12" \
"litellm>=1.0.0" \
"langgraph>=1.2.0" \
"langchain>=1.2.0" \
"langchain-core>=1.2.0" \
"databricks-langchain>=0.20"
dbutils.library.restartPython()
mlflow[databricks] を明示的に入れ直すのは、第1部第5章と同じ理由です(サーバーレスの
既定の mlflow は古く、mlflow.genai が使えません)。ただし、ここでは <3.12 と上限を付けて
います。上限が無いとその時点の最新版が選ばれ、LangChain 系のライブラリと組み合わせたときに
pip が互換する版を探して古い版へ遡り続け、インストールが終わらなくなることがあるためです
(実際に、上限なしでは15分たっても先頭のセルが終わりませんでした。上限を付けると1分以内に
解決します)。litellm は、後で使うLLM ジャッジが databricks:/ 形式のモデル指定を
解決するために必要なライブラリです。
import os
# 本体LLM: ツール呼び出しの信頼性のため 70b。Serving タブで実名を確認すること。
# Free Edition 実測(2026-07-21): 3-1-70b は存在せず、3-3-70b が実在しツール呼び出し可。
os.environ["LLM_ENDPOINT"] = "databricks-meta-llama-3-3-70b-instruct"
# 参照する整形済みビュー(Genie と同じビューを共有)。
os.environ["CANDIDATES_TABLE"] = "stock_analysis.gold.v_candidates"
# ジャッジ用 LLM。本体とは別モデルにして自己評価バイアスを避ける。
# Free Edition では claude / gemini / gpt 系は READY 表示でも rate limit 0 で呼べないため、
# 実際に使える llama 系を指定する。databricks:/ 形式は litellm 経由で呼ばれる。
os.environ["JUDGE_MODEL"] = "databricks:/databricks-llama-4-maverick"
JUDGE_MODEL = os.environ["JUDGE_MODEL"]
print("環境変数を設定しました")
ジャッジは本体と別のモデルにします。自分の回答を自分で採点させると、甘くなる方向に
偏ります(自己評価バイアス)。ここでは本体が Llama 3.3 70B、ジャッジが Llama 4 Maverick です。
import sys, os
# notebooks/ と agents/ は兄弟フォルダ(stock_analysis/ 直下)。
# ワークスペースでは stock_analysis/notebooks/ と stock_analysis/agents/ に配置される。
agents_dir = os.path.abspath("../agents")
if agents_dir not in sys.path:
sys.path.insert(0, agents_dir)
print("agents dir:", agents_dir)
ノートブックの実行時カレントディレクトリは、そのノートブックが置かれたフォルダです。
agents/ は兄弟フォルダなので ../agents を sys.path に足せば import できます。
import mlflow
# 本エージェント専用の experiment に記録する(ワークスペース絶対パスで指定)。
EXPERIMENT = "/Users/<あなたのユーザー>/stock_analysis/llmops_agent" # ★ 自分の環境の値に置き換える
experiment = mlflow.set_experiment(EXPERIMENT)
EXPERIMENT_ID = experiment.experiment_id
print("experiment:", EXPERIMENT, f"(id={EXPERIMENT_ID})")
モジュール側が決めなかった「どこへ記録するか」を、ここで決めます。experiment_id は
後でトレース検索とスコアラー登録に使うので、変数に取っておきます。
最初に壁を潰す(疎通確認)
import mlflow.deployments
deploy_client = mlflow.deployments.get_deploy_client("databricks")
def _ping(endpoint: str) -> None:
ep = endpoint.replace("databricks:/", "")
try:
resp = deploy_client.predict(
endpoint=ep,
inputs={"messages": [{"role": "user", "content": "hi"}], "max_tokens": 5},
)
print(f" OK {ep}: {resp['choices'][0]['message']['content']!r}")
except Exception as e:
print(f" NG {ep}: {type(e).__name__}: {str(e)[:160]}")
print("--- 本体LLM ---")
_ping(os.environ["LLM_ENDPOINT"])
print("--- ジャッジLLM ---")
_ping(JUDGE_MODEL)
出力はこのような形になります。
--- 本体LLM ---
OK databricks-meta-llama-3-3-70b-instruct: "Hello! It's nice"
--- ジャッジLLM ---
OK databricks-llama-4-maverick: 'hi! how are you'
エージェントを組んでから「そもそもジャッジのモデルが呼べなかった」と気付くのは、
いちばん無駄な回り道です。最初に、いちばん壊れやすいところを叩いておく。
Free Edition では特に、エンドポイント一覧に出ていても実際には呼べないモデルがあります
(claude / gemini / gpt 系は READY と表示されてもレート制限が 0)。_ping() が例外を
握って NG と印字するだけなのは、ここで止まらずに全部の結果を見たいからです。
Vibe Check(まず動かして、トレースを作る)
from screening_agent import ScreeningAgent, Thread
TEST_QUESTIONS = [
"スコア70点以上で配当利回りが高い銘柄を教えてください。",
"証券コード7203の長期保有の観点での評価を教えてください。",
"情報・通信業でスコアの高い候補はありますか?",
]
agent = ScreeningAgent()
for i, question in enumerate(TEST_QUESTIONS, 1):
print(f"--- 質問 {i}/{len(TEST_QUESTIONS)} ---")
print(f"Q: {question}")
try:
answer = agent.process_query(query=question, thread=Thread())
print("A:", (answer[:400] + "...") if len(answer) > 400 else answer)
except Exception as e:
print(f"エラー: {type(e).__name__}: {e}")
print()
print("Vibe Check 完了! Experiments > Traces でトレースを確認してください。")
出力はこのような形になります(回答は実行のたびに変わります。銘柄名を含む部分は省略しました)。
ScreeningAgent を初期化しました(ツール数: 2):
- search_candidates
- get_candidate_detail
--- 質問 1/3 ---
Q: スコア70点以上で配当利回りが高い銘柄を教えてください。
A: スコア70点以上で配当利回りが高い銘柄としては、以下の銘柄があります。
(銘柄ごとのスコアと配当利回りの一覧が続く。省略)
--- 質問 2/3 ---
Q: 証券コード7203の長期保有の観点での評価を教えてください。
A: 証券コード7203はスクリーニング通過銘柄に含まれていません。したがって、長期保有の観点での評価を行うことはできません。
留意点:長期保有を検討する場合、経済的・市場的リスク、業界の変化、会社の財務状況などの要素を考慮する必要があります。
本内容は投資判断の参考情報であり、投資助言・推奨ではありません。
--- 質問 3/3 ---
Q: 情報・通信業でスコアの高い候補はありますか?
A: 情報・通信業でスコアの高い候補としては、…(銘柄ごとの指標とリスクの論評が続く。省略)
Vibe Check 完了! Experiments > Traces でトレースを確認してください。
2問目は、通過銘柄に含まれない証券コードを聞いています。「無い」と答えたうえで、免責の一文まで
添えているのが、システムプロンプトの狙いどおりの動きです。
Vibe Check とは、まず数問投げて肌感を掴む工程のことです。ここで大事なのは、
人間が回答を読むことだけではありません。この実行で MLflow にトレースが記録されることが、
次のステップの材料になります。
質問は3種類——一覧検索・個別銘柄・業種指定——で、意図的に別のツールが呼ばれるように
選んでいます。左ナビゲーションの Experiments > Traces を開くと、1問ごとに
「LLM 呼び出し → ツール実行 → LLM 呼び出し」という内訳が時系列で見えます。
第1部第5章で見たトレースの、実戦版です。
スコアラーを定義する
評価の中身です。4種類を用意します。
import re
from mlflow.genai.scorers import scorer, Correctness, Guidelines, ToolCallCorrectness
# 標準スコアラー
correctness = Correctness(model=JUDGE_MODEL)
# ToolCallCorrectness は期待ツールと実際の呼び出しを LLM が意味的に比べる(model 未指定なら既定のジャッジ)
tool_call_correctness = ToolCallCorrectness()
@scorer
def mentions_risk(outputs: str) -> bool:
"""回答がリスク要因に言及しているかを検出する(ルールベース)。
システムプロンプトが「主なリスク要因に必ず触れる」と指示しているため、
その順守をキーワードで簡易チェックする。
"""
text = outputs if isinstance(outputs, str) else str(outputs)
keywords = ["リスク", "懸念", "注意", "債務", "競合", "サイクル", "変動", "鈍化", "下落"]
return any(k in text for k in keywords)
has_disclaimer = Guidelines(
name="has_disclaimer",
guidelines=[
"回答の中に、投資判断の参考情報であり投資助言・推奨ではない旨の免責が含まれている。",
],
model=JUDGE_MODEL,
)
print("スコアラー定義: correctness, tool_call_correctness, mentions_risk, has_disclaimer")
4つのスコアラーは、LLM ジャッジが3つ、ルールベースが1つです。
| スコアラー | 種類 | 何を見るか |
|---|---|---|
Correctness |
LLM ジャッジ | 期待する回答の要点をカバーしているか |
ToolCallCorrectness |
LLM ジャッジ(標準) | 期待したツールを実際に呼んだか |
mentions_risk |
ルールベース(自作) | リスクに関する語が含まれるか |
has_disclaimer |
LLM ジャッジ(Guidelines) |
免責の一文があるか |
ここには使い分けの原則があります。機械的に判定できるものは機械で判定する。
リスクへの言及は、キーワードで十分に拾えます(厳密ではありませんが、安く・速く・
毎回同じ結果という利点が勝ちます)。それで mentions_risk は自作のルールベースにしました。
一方、「期待した内容を答えているか」「免責として成立しているか」は、表現の揺れを
許容しながら判定する必要があるので、LLM ジャッジの出番です。Guidelines は
自然文のルールを渡すだけで判定器になる、便利な仕組みです。
ToolCallCorrectness は少し注意が要ります。ツールを呼んだかどうかはトレースを見れば分かる
事実ですが、標準の動作(should_exact_match=False)では、期待したツールと実際の呼び出しを
LLM が意味的に比べます。ここでは model を渡していないので、既定のジャッジが使われます。
名前と引数を機械的に突き合わせたいときは should_exact_match=True にします。
@scorer デコレータを付けた関数は、引数名が意味を持ちます。outputs という名前にすると
「エージェントの出力」が渡されます。
スコアラーを個別に試す
いきなり全件評価にかけず、1トレースで挙動を確かめます。
import mlflow
def get_latest_traces(max_results: int = 5):
return mlflow.search_traces(
locations=[EXPERIMENT_ID],
max_results=max_results,
order_by=["timestamp DESC"],
return_type="list",
)
def print_result(result, name: str = ""):
if not hasattr(result, "name"):
print(f" name: {name}\n value: {result}\n")
return
print(f" name: {result.name}\n value: {result.value}")
if getattr(result, "rationale", None):
rationale = result.rationale
print(f" rationale: {rationale[:200] + '...' if len(rationale) > 200 else rationale}")
print()
traces = get_latest_traces(max_results=3)
assert traces, "トレースがありません。先に Vibe Check のセルを実行してください。"
trace = traces[0]
print(f"トレースID: {trace.info.trace_id}\n")
# --- ToolCallCorrectness(ルールベース)---
print("--- ToolCallCorrectness ---")
print_result(
tool_call_correctness(
trace=trace,
expectations={"expected_tool_calls": [{"name": "search_candidates"}]},
)
)
# --- mentions_risk(@scorer ルールベース)---
print("--- mentions_risk ---")
outputs = trace.data.spans[0].outputs
output_text = outputs if isinstance(outputs, str) else str(outputs)
print_result(mentions_risk(outputs=output_text), name="mentions_risk")
# --- has_disclaimer(Guidelines)---
print("--- has_disclaimer ---")
print_result(has_disclaimer(trace=trace))
# --- Correctness(LLMジャッジ)---
print("--- Correctness ---")
expected = (
"情報・通信業の候補からスコアの高い銘柄を挙げ、"
"長期保有目線の要点とリスク、免責を添えて回答する。"
)
print_result(correctness(trace=trace, expectations={"expected_response": expected}))
出力はこのような形になります(判定理由 rationale は先頭200文字で切れます)。
トレースID: tr-feb7838d97f9082d7c449a503dba8985
--- ToolCallCorrectness ---
name: tool_call_correctness
value: yes
rationale: Let's think step by step. The agent correctly selected the tool 'search_candidates', which matches the expected tool name. The arguments provided are reasonable given the user request and the tool's d...
--- mentions_risk ---
name: mentions_risk
value: True
--- has_disclaimer ---
name: has_disclaimer
value: yes
rationale: The given guidelines require that the response includes a disclaimer stating that the content is for reference in investment decisions and not investment advice or recommendation. The provided respons...
--- Correctness ---
name: correctness
value: yes
rationale: The claim asks whether the document supports the statement that it lists high-scoring candidates in the information and communication industry, along with key points and risks for long-term holdi...
ここで使っているのは、Vibe Check の最新のトレース=3問目(情報・通信業)です。そのため、
期待回答(expected)も3問目に合わせて書いています。mentions_risk だけは True / False
(ルールベース)、ほかは yes / no と判定理由(LLM ジャッジ)が返ります。
スコアラーは、トレースを入力に取ります。 trace=trace と expectations={...} を
渡すと、その1件について判定が返ります。LLM ジャッジの場合は rationale(判定理由)も
付いてくるので、print_result() で先頭200文字を出しています。なぜその点数なのかを
読めることが、ジャッジを信用できるかどうかの分かれ目です。
assert traces, "..." は、実行順の取り違えを分かりやすく落とすためのものです。
先に Vibe Check を走らせていなければ、そもそも採点する対象がありません。
スコアラーを登録する
from mlflow.genai import get_scorer
from mlflow.genai.scorers import list_scorers
registered = {s.name for s in list_scorers(experiment_id=EXPERIMENT_ID)}
for s in (has_disclaimer,):
if s.name in registered:
print(f" {s.name}: 登録済みのためスキップ")
else:
s.register(experiment_id=EXPERIMENT_ID)
print(f" {s.name}: 登録完了")
# 取得テスト(Databricks はバージョン指定取得に非対応のため version は指定しない)
loaded = get_scorer(name="has_disclaimer", experiment_id=EXPERIMENT_ID)
print(" 取得成功:", loaded.name)
出力はこのような形になります(初回は「登録完了」、2回目以降は「登録済みのためスキップ」)。
has_disclaimer: 登録済みのためスキップ
取得成功: has_disclaimer
Guidelines スコアラーは experiment に登録してバージョン管理できます。評価基準そのものを
資産として残す、という考え方です。
ただし Databricks では register() が冪等ではありません。ノートブックは何度も
実行するものなので、そのままだと実行のたびに登録が積み上がります。そこで
list_scorers() で既存を調べ、あればスキップします。第3章の「Gold を読んで Gold に
書くノートブックは冪等にする」と同じ発想が、ここでも要ります。
評価データセット
eval_dataset = [
{
"inputs": {"question": "スコア70点以上で配当利回りが高い銘柄を教えてください。"},
"expectations": {
"expected_response": (
"スコア70点以上の候補から配当利回りの高い銘柄を挙げ、"
"長期保有目線の要点とリスク要因、免責を添えて回答する。"
),
"expected_tool_calls": [{"name": "search_candidates"}],
},
},
{
"inputs": {"question": "証券コード7203の長期保有の観点での評価を教えてください。"},
"expectations": {
"expected_response": (
"指定銘柄の指標(収益品質・成長性・バリュエーション・財務健全性)に基づき、"
"長期保有目線の評価とリスク、免責を添えて回答する。"
"通過銘柄でなければその旨を伝える。"
),
"expected_tool_calls": [{"name": "get_candidate_detail"}],
},
},
{
"inputs": {"question": "PERが15倍以下で財務が健全な候補はありますか?"},
"expectations": {
"expected_response": (
"PER15倍以下の候補を挙げ、財務健全性(D/E・流動比率)に触れ、"
"リスクと免責を添えて回答する。"
),
"expected_tool_calls": [{"name": "search_candidates"}],
},
},
{
"inputs": {"question": "医薬品業種の候補をスコア順に見せてください。"},
"expectations": {
"expected_response": (
"医薬品業種の候補をスコア順に挙げ、各銘柄の要点とリスク、免責を添えて回答する。"
"該当がなければその旨を伝える。"
),
"expected_tool_calls": [{"name": "search_candidates"}],
},
},
{
"inputs": {"question": "証券コード9999の評価を教えてください。"},
"expectations": {
"expected_response": (
"証券コード9999はスクリーニング通過銘柄に含まれないため、"
"その旨を正直に伝える。"
),
"expected_tool_calls": [{"name": "get_candidate_detail"}],
},
},
]
print(f"評価データセット: {len(eval_dataset)}件")
5件だけの小さなデータセットですが、選び方に意図があります。
- 1件目・3件目・4件目は一覧検索。条件の種類(スコア/バリュエーション/業種)を変える
- 2件目は個別銘柄の深掘り。別のツールが呼ばれることを期待する
- 5件目が要です。存在しない証券コード9999を聞いて、「答えないことを期待する」
最後のケースが、このデータセットでいちばん重要です。エージェントの評価というと「良い答えを
返せるか」に目が行きますが、答えられないときに正直に言えるかは、それと同じくらい
——実用上はそれ以上に——大事です。期待する挙動として明示的にテストに含めます。
expectations に expected_response(自然文)と expected_tool_calls(ツール名)の
両方を書いているのは、前節の4つのスコアラーがそれぞれ違う鍵を見るからです。
評価を実行する
import mlflow
def predict_fn(question: str) -> str:
"""評価用の predict 関数。データセットの inputs["question"] に対応する。"""
return agent.process_query(query=question, thread=Thread())
print("評価を開始します...\n")
eval_results = mlflow.genai.evaluate(
data=eval_dataset,
predict_fn=predict_fn,
scorers=[correctness, tool_call_correctness, mentions_risk, has_disclaimer],
)
print("\n評価完了!")
print("run_id:", eval_results.run_id)
display(eval_results.metrics)
出力はこのような形になります(進捗バーとログは省略しました)。
評価を開始します...
評価完了!
run_id: 74d9a7a07cf14c40815280e7c93865c3
{'mentions_risk/mean': 1.0,
'tool_call_correctness/mean': 1.0,
'has_disclaimer/mean': 1.0,
'correctness/mean': 1.0}
<スコアラー名>/mean が、5件の平均です。mentions_risk と has_disclaimer は True/False・
yes/no を 1/0 にして平均しています。
mlflow.genai.evaluate() が、データセットの各行に対して predict_fn を呼び、
返ってきた回答(とトレース)を4つのスコアラーにかけ、結果を MLflow の run にまとめます。
predict_fn の引数名 question が、データセットの inputs のキーと対応しています。
thread=Thread() を毎回新規に作っているのは、評価では前の質問の記憶を持ち越さない
ためです。会話履歴が残っていると、前の回答に引きずられて採点対象がぶれます。
LLM ジャッジの呼び出しが行数×3回(Correctness・ToolCallCorrectness・has_disclaimer)発生するので、5件でも数分かかります。
評価が改善を捉える
ここからが、この章でいちばん伝えたいところです。
初回の評価で、mentions_risk/mean が 0 になりました。人間が読んだ限りでは回答は
まともに見えます。しかし調べると、一覧形式で答えるときだけリスクに触れていない
ことが分かりました。1銘柄を深掘りするときは自然にリスクを述べるのに、複数銘柄を
並べるときは列挙して終わってしまう。読んでいるだけでは気付きにくい傾向です。
そこでシステムプロンプトに、「複数銘柄を一覧で答える場合も、回答の末尾に『留意点』として
長期保有で共通して注意すべきリスクを最低1文添える」を追加し、再評価しました。
| スコアラー | 修正前 | 修正後 |
|---|---|---|
mentions_risk/mean |
0 | 1.0 |
correctness/mean |
0.4 | 0.8 |
tool_call_correctness/mean |
1.0 | 1.0 |
has_disclaimer/mean |
1.0 | 1.0 |
この表の値は、プロンプトを直した当時(2026-07)の実行結果です。LLM ジャッジは実行のたびに
揺れるので、同じ評価を何度か回すと correctness/mean は 0.6 や 1.0 になることもあります
(2026-10-31 の再実行では 0.6 と 1.0 でした)。見るべきは細かい小数ではなく、mentions_risk が
0 から 1.0 へ動いたような大きな差です。
リスク言及の改善が、correctness の改善も連れてきた点に注目してください。期待回答に
「リスク要因を添えて」と書いてあったので、ジャッジもそこを見ていたわけです。
この一連が、第3章のコラムで置いた問いへの答えです。AI の出力を毎回人間が読む代わりに、
評価の数値で捉える。 そして数値が動いたら、その原因を探しに行く。プロンプトを直したら、
また測る。「なんとなく良くなった気がする」を、比較可能な数字に変えるのが、LLMOps の
最初の一歩です。
ステップ3:プロンプトを管理する(notebooks/06b_prompt_registry.py)
システムプロンプトは、いまモジュールにハードコードされています。前節で見たとおり、
プロンプトは今後も直され続けるもの——つまりバージョン管理の対象です。
Unity Catalog の Prompt Registry に登録すれば、バージョンが積まれ、エイリアスで
「どれが本番か」を指せるようになります。
%pip install -qU \
"mlflow[databricks]>=3.9.0,<3.12" \
"langgraph>=1.2.0" \
"langchain>=1.2.0" \
"langchain-core>=1.2.0" \
"databricks-langchain>=0.20"
dbutils.library.restartPython()
import os, sys
os.environ["LLM_ENDPOINT"] = "databricks-meta-llama-3-3-70b-instruct"
# agents/ を import パスに追加(SYSTEM_PROMPT を読むため)
agents_dir = os.path.abspath("../agents")
if agents_dir not in sys.path:
sys.path.insert(0, agents_dir)
print("agents dir:", agents_dir)
import mlflow
# プロンプトは Unity Catalog に格納されるため、レジストリを UC に向ける
mlflow.set_registry_uri("databricks-uc")
CATALOG = "stock_analysis"
SCHEMA = "prompts" # プロンプト格納用スキーマ(ライフサイクルが gold と違うため新設)
PROMPT_NAME = f"{CATALOG}.{SCHEMA}.screening-agent-system-prompt"
# プロンプトの格納先スキーマ。CREATE FUNCTION / EXECUTE / MANAGE 権限が要る。
spark.sql(f"CREATE SCHEMA IF NOT EXISTS {CATALOG}.{SCHEMA}")
print("プロンプト名:", PROMPT_NAME)
プロンプトは gold ではなく prompts という専用スキーマに置きます。データとは
ライフサイクルが違う(週次で作り直されるものではない)ためです。名前は
catalog.schema.name の3レベルが必須で、2レベルは拒否されます。第1部第4章で学んだ
3階層の命名規則が、ここでも効いてきます。
冪等に登録する
from mlflow import MlflowClient
_client = MlflowClient()
def find_version_with_template(name: str, template: str):
"""同じ template を持つ既存バージョンを探す。無ければ None。"""
try:
versions = _client.search_prompt_versions(name)
except Exception:
return None # プロンプト自体が未登録
for v in versions:
loaded = mlflow.genai.load_prompt(f"prompts:/{name}/{v.version}")
if loaded.template.strip() == template.strip():
return loaded
return None
def register_if_new(name: str, template: str, **kwargs):
"""同じ template のバージョンが既にあれば再登録しない(冪等化)。
Databricks でもプロンプトのバージョンは不変で、register_prompt() を呼ぶたびに
新バージョンが積まれる。ノートブックは繰り返し実行するため、全バージョンを
走査して同一内容があればスキップする。
"""
existing = find_version_with_template(name, template)
if existing is not None:
print(f" → v{existing.version} と同一内容のためスキップ(再登録しない)")
return existing
return mlflow.genai.register_prompt(name=name, template=template, **kwargs)
register_prompt() は呼ぶたびに新バージョンを積みます。ノートブックを3回実行したら
中身が同じ v1・v2・v3 ができてしまう。そこで全バージョンを走査して同一内容があれば
スキップします。スコアラー登録と同じ問題で、同じ対処です。
# エージェント本体にハードコードされているシステムプロンプトをそのまま登録する
from screening_agent import SYSTEM_PROMPT
prompt = register_if_new(
name=PROMPT_NAME,
template=SYSTEM_PROMPT,
commit_message="スクリーニングエージェントのシステムプロンプト(長期保有目線・リスク言及・免責必須)",
tags={"author": "<あなたのユーザー>", "task": "screening-qa", "language": "ja"}, # ★ 自分の環境の値に置き換える
)
print(f"プロンプト '{prompt.name}' (version {prompt.version})")
print("--- 登録内容(先頭200文字)---")
print(SYSTEM_PROMPT[:200])
出力はこのような形になります(初回は「スキップ」の行が出ず、version 1 と表示されます。
下は、プロンプトを2回改訂したあとで同じ内容のまま再実行した例です)。
→ v3 と同一内容のためスキップ(再登録しない)
プロンプト 'stock_analysis.prompts.screening-agent-system-prompt' (version 3)
--- 登録内容(先頭200文字)---
あなたは東証上場株の中長期投資候補を分析するアシスタントです。
利用者は3〜5年の長期保有を前提に、スクリーニングを通過した候補銘柄について質問します。
あなたの責務:
- 提供されたツールを使ってスクリーニング通過銘柄の情報を取得し、事実に基づいて回答する
- 一時的な株価変動ではなく、ビジネスモデルの持続性・競争優位性・財務健全性を重視する
必ず守ること:
- 数値や銘柄の情報は、必ずツー
登録するのは、モジュールの SYSTEM_PROMPT そのものです。二重管理を避けるため、
プロンプトの文面を書き写すのではなく import して渡します。
commit_message は Git のコミットメッセージと同じ役割で、「このバージョンで何を意図したか」を
残します。
@production エイリアス
# デプロイノートブック(06c)が読むのはこの @production。
mlflow.genai.set_prompt_alias(
name=PROMPT_NAME,
alias="production",
version=prompt.version,
)
# 確認: @production でロードできること(@latest は Databricks 非対応なので使わない)
prod = mlflow.genai.load_prompt(f"prompts:/{PROMPT_NAME}@production")
print(f"@production → バージョン {prod.version}")
print("エイリアスを別バージョンに向け直せば、06c を再実行(再デプロイ)するだけで本番プロンプトを切り替えられます。")
出力はこのような形になります。
@production → バージョン 3
エイリアスを別バージョンに向け直せば、06c を再実行(再デプロイ)するだけで本番プロンプトを切り替えられます。
エイリアスは「どのバージョンが本番か」を指す可動式のラベルです。バージョン自体は不変で、
ラベルだけを付け替えます。プロンプトを改訂したら、新バージョンを登録して @production を
向け直す。問題があれば前のバージョンに戻す。モデルのデプロイと同じ運用を、プロンプトに
持ち込んでいます。
なお、Databricks では予約エイリアス @latest が使えません。「最新」を指したいときは
バージョン検索で代替します。
# Catalog Explorer には出ないため、検索 API で確認する
found = mlflow.genai.search_prompts(
filter_string=f"catalog = '{CATALOG}' AND schema = '{SCHEMA}'"
)
for p in found:
print("-", p.name)
出力はこのような形になります。
- stock_analysis.prompts.screening-agent-system-prompt
登録したプロンプトは Catalog Explorer には表示されません(テーブルでも関数でもない UC
オブジェクトのため)。「登録したはずなのに画面に出ない」と焦らないよう、検索 API で
確認する手順を最後に置いています。
ステップ4:公開する(agents/chat_agent.py と notebooks/06c_deploy_agent.py)
最後に、エージェントを Model Serving エンドポイントとして公開します。デプロイすれば、
Playground からチャットで話しかけたり、OpenAI 互換の API として叩いたりできます。
事前準備:エージェント用のサービスプリンシパルを作る
デプロイの前に、一度だけ済ませておく作業があります。**エージェントがデータを読むための
サービスプリンシパル(SP)**を用意します。
なぜ必要かは後述のコラムで説明しますが、結論だけ先に言うと、デプロイ時に Databricks が
自動生成するアカウントには、SQL を実行する資格が付いてこないからです。自分で所有する SP を
立てて、必要な権限を明示的に付けます。
以下は CLI での例です(UI からでも同じことができます)。
# 1. SP を作成し、SQL を使う資格(エンタイトルメント)を付ける
databricks service-principals create --json '{
"displayName": "stock-analysis-agent-sp",
"active": true,
"entitlements": [
{"value": "workspace-access"},
{"value": "databricks-sql-access"}
]
}'
# → 応答の applicationId(UUID)と id(数値)を控える
# 2. SQL ウェアハウスを使う権限
databricks permissions update warehouses <あなたのウェアハウスID> --json '{
"access_control_list": [
{"service_principal_name": "<applicationId>", "permission_level": "CAN_USE"}
]
}'
# 3. Unity Catalog 側の読み取り権限
databricks grants update CATALOG stock_analysis \
--json '{"changes":[{"principal":"<applicationId>","add":["USE CATALOG"]}]}'
databricks grants update SCHEMA stock_analysis.gold \
--json '{"changes":[{"principal":"<applicationId>","add":["USE SCHEMA"]}]}'
databricks grants update TABLE stock_analysis.gold.v_candidates \
--json '{"changes":[{"principal":"<applicationId>","add":["SELECT"]}]}'
# 4. OAuth シークレットを発行
databricks service-principal-secrets-proxy create <id(数値のほう)>
# → 応答の secret を控える(この一度しか表示されない)
# 5. シークレットスコープに格納(コードから平文で参照しないため)
databricks secrets create-scope stock_analysis_agent
databricks secrets put-secret stock_analysis_agent client_id --string-value "<applicationId>"
databricks secrets put-secret stock_analysis_agent client_secret --string-value "<発行された secret>"
権限はこの4種類だけです。エージェントは v_candidates を読むだけなので、それ以上は
与えません。SP を自分で持つ利点はここにあります——何を許したかが一覧で分かり、
必要な分だけ足せる。
なぜ PAT ではなく OAuth なのか: OAuth(M2M)のシークレットは SDK が自動で
アクセストークンに交換し、期限が来れば更新します。個人アクセストークンを埋め込むより
運用が楽で、期限切れの事故も起きにくくなります。
ChatAgent でラップする
Mosaic AI Agent Framework にデプロイするには、ChatAgent インターフェースに適合させる
必要があります。そのラッパーが agents/chat_agent.py です。
"""Mosaic AI Agent Framework 用の ChatAgent ラッパー。
`ScreeningAgent`(LangGraph)を `mlflow.pyfunc.ChatAgent` インターフェースに
適合させ、Model Serving にデプロイ可能にする。チャット形式(Playground / OpenAI 互換 API)で
呼び出せるようにするのが目的。
## プロンプトの扱い(案①: デプロイ時に読み込む)
システムプロンプトは Unity Catalog の Prompt Registry で管理するが、**読み込みはデプロイ時**に行う。
デプロイノートブック(`06c_deploy_agent.py`)が `@production` のテンプレートを解決し、
`log_model(model_config={"system_prompt": ...})` でモデルへ焼き込む。本モジュールは
`ModelConfig` からそれを読むだけで、**サービング実行時に UC プロンプトへはアクセスしない**。
代償として、エイリアスを別バージョンに向けたら**再デプロイが必要**。リクエスト毎に読み込む構成に
すれば再デプロイなしで切り替えられる(トレードオフ。本記事では「プロンプトを『いつ』読むか」のコラムで比べている)。
## 認証の扱い(専用サービスプリンシパル)
データ取得は **本プロジェクト専用のサービスプリンシパル `stock-analysis-agent-sp`** の資格情報で行う。
`predict()` の中で `DATABRICKS_CLIENT_ID` / `DATABRICKS_CLIENT_SECRET`(デプロイ時に
`agents.deploy(environment_vars=...)` からシークレット参照で注入)を読み、OAuth M2M の
`WorkspaceClient` を組み立てて `screening_agent.request_scope()` に載せる。
### なぜ自動認証パススルーだけでは足りないのか
当初は自動認証パススルー(`log_model(resources=[...])`)だけで足りると考えていた。しかし
**パススルーが渡すのはリソース単位の権限(ウェアハウスの Use Endpoint、テーブルの SELECT)であって、
`databricks-sql-access` のようなワークスペースのエンタイトルメントは渡らない**。
デプロイ時に Databricks が自動生成するサービスプリンシパルがその資格を持たないと、リソースをいくら
宣言しても Statement Execution API が `PermissionDenied` で落ちる(2026-08-15 に実際に発生)。
自動生成されるサービスプリンシパルは Databricks が所有・ローテーションする対象で、こちらから
エンタイトルメントを管理できない。そこで**自分で所有する SP** を立て、必要な資格
(`databricks-sql-access`、ウェアハウスの CAN_USE、UC の SELECT)を明示的に付与する。
権限の所在が1つのアカウントに集約され、必要なものだけを自分で足せるのが利点。
**注**: 資格情報の組み立ては `__init__` ではなく `predict()` で行う。リクエストの外で
クライアントを持ち回らず、スコープの寿命をリクエストに揃えるため。
"""
import os
from contextlib import nullcontext
import mlflow
from mlflow.models import ModelConfig
# 設定はデプロイ時に log_model(model_config=...) で焼き込む。
# development_config はローカル/デプロイ前検証時のフォールバック。
_config = ModelConfig(
development_config={
"system_prompt": None, # None → screening_agent の SYSTEM_PROMPT を使う
"llm_endpoint": "databricks-meta-llama-3-3-70b-instruct",
"candidates_table": "stock_analysis.gold.v_candidates",
"warehouse_id": "<あなたのウェアハウスID>", # ★ 自分の環境の値に置き換える
}
)
# screening_agent はモジュール定数(LLM_ENDPOINT 等)を import 時に os.environ から読むため、
# import より前に環境変数を設定しておく。
os.environ.setdefault("LLM_ENDPOINT", _config.get("llm_endpoint"))
os.environ.setdefault("CANDIDATES_TABLE", _config.get("candidates_table"))
os.environ.setdefault("WAREHOUSE_ID", _config.get("warehouse_id"))
import uuid
from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage, SystemMessage
from mlflow.models import set_model
from mlflow.pyfunc import ChatAgent
from mlflow.types.agent import ChatAgentChunk, ChatAgentMessage, ChatAgentResponse
from screening_agent import ScreeningAgent, SYSTEM_PROMPT, request_scope
# デプロイ時に解決済みのプロンプトがあればそれを、無ければモジュール定数を使う。
_SYSTEM_PROMPT = _config.get("system_prompt") or SYSTEM_PROMPT
def _data_request_scope():
"""データ取得用のクライアントを組み立て、リクエストスコープに載せる。
サービング時は `agents.deploy(environment_vars=...)` が注入した専用 SP の
OAuth 資格情報を使う。ノートブック等サービング外では環境変数が無いため
何もしないコンテキストを返し、`screening_agent` 側の既定経路
(Spark があれば `spark.table`、無ければ既定認証の SQL ウェアハウス)へ委ねる。
"""
client_id = os.environ.get("DATABRICKS_CLIENT_ID")
client_secret = os.environ.get("DATABRICKS_CLIENT_SECRET")
if not client_id or not client_secret:
return nullcontext()
try:
from databricks.sdk import WorkspaceClient
# host は SDK が DATABRICKS_HOST から解決する。client_id/secret を明示することで
# サービングの既定資格情報(自動生成 SP)ではなく専用 SP を確実に使う。
return request_scope(WorkspaceClient(client_id=client_id, client_secret=client_secret))
except Exception as e:
print(f"サービスプリンシパルのクライアントを作成できませんでした。既定の認証を使います: {e}")
return nullcontext()
import の順序が普通ではないことに気付いたでしょうか。os.environ.setdefault(...) を
挟んでから、後半の import が続いています。これは意図的です。screening_agent は
import された瞬間に os.environ からモジュール定数(LLM_ENDPOINT など)を読みます。
先に import してしまうと、設定が反映されません。「設定 → import」の順序を守るために、
あえて import を分けています。
class ScreeningChatAgent(ChatAgent):
"""`ScreeningAgent` を ChatAgent として公開するラッパー。"""
def __init__(self):
self._agent = ScreeningAgent(system_prompt=_SYSTEM_PROMPT)
def _to_lc_messages(self, messages):
"""会話履歴全体を LangChain メッセージへ変換(マルチターン対応)。"""
lc = [SystemMessage(content=_SYSTEM_PROMPT)]
for m in messages:
if m.role == "user":
lc.append(HumanMessage(content=m.content))
elif m.role == "assistant":
lc.append(AIMessage(content=m.content))
return lc
def predict(self, messages, context=None, custom_inputs=None):
lc = self._to_lc_messages(messages)
# リクエストごとに独立した thread_id でコンパイル済みグラフを実行
config = {"configurable": {"thread_id": str(uuid.uuid4())}}
# ツールのデータ取得はこのスコープの中でだけ専用 SP の資格情報を使う
with _data_request_scope():
result = self._agent.executor.invoke({"messages": lc}, config=config)
answer = ""
for msg in reversed(result["messages"]):
if isinstance(msg, AIMessage):
answer = msg.content
break
return ChatAgentResponse(
messages=[ChatAgentMessage(role="assistant", content=answer, id=str(uuid.uuid4()))]
)
def predict_stream(self, messages, context=None, custom_inputs=None):
# predict_stream が無いと Review App / Playground が毎回
# 「ストリーミング失敗→非ストリーミングで再試行」を表示する。LLM のトークンを逐次返す。
lc = self._to_lc_messages(messages)
config = {"configurable": {"thread_id": str(uuid.uuid4())}}
msg_id = str(uuid.uuid4())
streamed = False
# ストリーミング中もツールが動くため、生成が終わるまでスコープを開けておく
with _data_request_scope():
for chunk, _meta in self._agent.executor.stream(
{"messages": lc}, config=config, stream_mode="messages"
):
# 最終回答のトークンだけを流す(ツール判断ターンは content が空なのでスキップ)
if isinstance(chunk, AIMessageChunk) and chunk.content:
streamed = True
yield ChatAgentChunk(
delta=ChatAgentMessage(role="assistant", content=chunk.content, id=msg_id)
)
# トークンが取れなかった場合は一括応答をフォールバックで返す(表示崩れ防止)
if not streamed:
for msg in self.predict(messages, context, custom_inputs).messages:
yield ChatAgentChunk(delta=msg)
set_model(ScreeningChatAgent())
サービングでは、会話履歴はリクエストのたびに丸ごと送られてきます。そのため
_to_lc_messages() で履歴全体を LangChain のメッセージに変換し、thread_id は
毎回新しい UUID を振ります。ノートブックでの Thread によるマルチターンとは、
状態の持ち方が逆になる点に注意してください。
predict() と predict_stream() が with _data_request_scope(): で実行しているのが、
前述のリクエストスコープです。_data_request_scope() は、環境変数 DATABRICKS_CLIENT_ID /
DATABRICKS_CLIENT_SECRET(デプロイ時にシークレットから注入されます)があれば、それで
WorkspaceClient を組み立てて request_scope() に載せます。ノートブックなど環境変数が
無い場所では何もしない(nullcontext)ので、同じラッパーがノートブックでも動き、
screening_agent は Spark の経路へ進みます。資格情報の組み立てを __init__ でなく predict() の
中で行うのは、スコープの寿命をリクエストに揃えるためです。
predict_stream() を実装しているのは、体感品質のためです。これが無いと Playground が
毎回「ストリーミング失敗 → 非ストリーミングで再試行」と表示します。ツール判断のターンは
content が空なので、中身のあるチャンクだけを流すようにフィルタしています。
最後の set_model(...) が Models from Code の作法です。「このファイルを実行したとき、
この変数がモデルである」と宣言します。
デプロイ前の検証
%pip install -qU \
"mlflow[databricks]>=3.9.0,<3.12" \
"databricks-agents>=1.1" \
"databricks-langchain>=0.20" \
"langgraph>=1.2.0" \
"langchain>=1.2.0" \
"langchain-core>=1.2.0"
dbutils.library.restartPython()
import os, sys
os.environ["LLM_ENDPOINT"] = "databricks-meta-llama-3-3-70b-instruct"
os.environ["CANDIDATES_TABLE"] = "stock_analysis.gold.v_candidates"
os.environ["WAREHOUSE_ID"] = "<あなたのウェアハウスID>" # ★ 自分の環境の値に置き換える
agents_dir = os.path.abspath("../agents")
if agents_dir not in sys.path:
sys.path.insert(0, agents_dir)
print("agents dir:", agents_dir)
デプロイ前に、サービング環境で通る経路だけを先に叩きます。
import screening_agent
df = screening_agent._load_via_sql_warehouse()
assert len(df) > 0, "候補が0件です。v_candidates を確認してください。"
assert df["score"].dtype.kind in "fi", f"score が数値型になっていません: {df['score'].dtype}"
print(f"SQL 経路OK: {len(df)} 件取得、最高スコア {df['score'].max():.0f}")
print("列:", list(df.columns))
出力はこのような形になります。
SQL 経路OK: 32 件取得、最高スコア 90
列: ['rank', 'code', 'name', 'market_segment', 'industry_33', 'current_price', 'per', 'pbr', 'dividend_yield_pct', 'roe_pct', 'roa_pct', 'gross_margin_pct', 'fcf_margin_pct', 'revenue_growth_pct', 'earnings_growth_pct', 'operating_margin_pct', 'debt_to_equity', 'current_ratio', 'momentum_12m_pct', 'score', 'ai_comment', 'screened_date']
ノートブックには Spark があるので、普通に動かすと spark.table() の経路しか通りません。
デプロイ後にしか使われない経路が壊れていても気付けないわけです。そこで
_load_via_sql_warehouse() を直接呼び、候補が取れること・score が数値型であることを
確認します(前述のとおり、SQL 経由では数値が文字列で返るため)。
df["score"].dtype.kind in "fi" は「float か int か」の判定です。ここが崩れると、
ツールのフィルタが静かに壊れます。デプロイ後に全リクエストが落ちる不具合を、
デプロイ前に潰すためのセルです。
ただし、このセルが潰せるのは経路とデータ形の問題だけです。ここは
あなたの資格情報で走りますが、サービング時に動くのは専用サービスプリンシパルです。
実行主体が違うので、ここが通ってもデプロイ後に権限で落ちることはあり得ます。
実際に筆者はそれを踏みました(後述のコラム)。権限まで含めた確認は、デプロイ後に
エンドポイントを実際に叩くまでできません。
import mlflow
mlflow.set_registry_uri("databricks-uc")
PROMPT_NAME = "stock_analysis.prompts.screening-agent-system-prompt"
prod_prompt = mlflow.genai.load_prompt(f"prompts:/{PROMPT_NAME}@production")
SYSTEM_PROMPT_TEXT = prod_prompt.template
print(f"@production → バージョン {prod_prompt.version}")
print("先頭120文字:", SYSTEM_PROMPT_TEXT[:120])
出力はこのような形になります。
@production → バージョン 3
先頭120文字: あなたは東証上場株の中長期投資候補を分析するアシスタントです。
利用者は3〜5年の長期保有を前提に、スクリーニングを通過した候補銘柄について質問します。
あなたの責務:
- 提供されたツールを使ってスクリーニング通過銘柄の情報を取得し、事
ここが、前節の Prompt Registry と繋がる部分です。デプロイ時に @production を解決し、
テンプレート文字列を取り出します。
# ノートブックには SP の資格情報(DATABRICKS_CLIENT_ID/SECRET)が無いため、_data_request_scope()
# は何もしないコンテキストを返し、screening_agent は Spark の table 経路にフォールバックする。
# ここでは ChatAgent ラッパーが応答を返すことを確認する。
from chat_agent import ScreeningChatAgent
from mlflow.types.agent import ChatAgentMessage
_agent = ScreeningChatAgent()
resp = _agent.predict(
[ChatAgentMessage(role="user", content="スコア70点以上の候補を1件教えてください。", id="1")]
)
print(resp.messages[0].content[:400])
ラッパー越しに1問投げて、応答が返ることを確かめます。デプロイして初めて動かすという
状況を避けるための、最後のリハーサルです。
出力はこのような形になります(回答の本文は銘柄名を含むため省略しました)。
ScreeningAgent を初期化しました(ツール数: 2):
- search_candidates
- get_candidate_detail
ScreeningAgent を初期化しました(ツール数: 2):
- search_candidates
- get_candidate_detail
スコア70点以上の候補として、…(銘柄の指標と留意点が続く。省略)
本内容は投資判断の参考情報であり、投資助言・推奨ではありません。
初期化のメッセージが2回出るのは、chat_agent.py の末尾にある set_model(ScreeningChatAgent()) が、
import した時点でも1つ作られるためです。
モデルをログしてデプロイする
import mlflow
from mlflow.models.resources import DatabricksServingEndpoint, DatabricksSQLWarehouse
mlflow.set_registry_uri("databricks-uc")
# デプロイ後のリアルタイムトレースを Playground で見られるよう、ログ前に experiment を指定する
mlflow.set_experiment("/Users/<あなたのユーザー>/stock_analysis/llmops_agent") # ★ 自分の環境の値に置き換える
# エージェント資産の登録先スキーマ(gold/prompts とはライフサイクルが違うため新設)
spark.sql("CREATE SCHEMA IF NOT EXISTS stock_analysis.agents")
UC_MODEL = "stock_analysis.agents.screening_agent"
# ── 自動認証パススルーで足りるもの ───────────────────────────────────────
# LLM 推論は自動生成 SP の資格で問題なく通っている(エンタイトルメント不要のため)。
# ウェアハウスとテーブルも宣言はしておく。実際のデータ取得は専用 SP が行うが、
# 依存関係をモデルのメタデータとして残す意味がある(リネージ・可搬性)。
resources = [
DatabricksServingEndpoint(endpoint_name="databricks-meta-llama-3-3-70b-instruct"),
DatabricksSQLWarehouse(warehouse_id="<あなたのウェアハウスID>"), # ★ 自分の環境の値に置き換える
]
try:
from mlflow.models.resources import DatabricksTable
resources.append(DatabricksTable(table_name="stock_analysis.gold.v_candidates"))
except Exception as e:
print("DatabricksTable リソースは未対応のためスキップ:", e)
with mlflow.start_run():
logged = mlflow.pyfunc.log_model(
name="agent",
python_model=f"{agents_dir}/chat_agent.py", # Models from Code
code_paths=[f"{agents_dir}/screening_agent.py"], # 依存モジュールを同梱
model_config={ # 案①: プロンプトを焼き込む
"system_prompt": SYSTEM_PROMPT_TEXT,
"llm_endpoint": "databricks-meta-llama-3-3-70b-instruct",
"candidates_table": "stock_analysis.gold.v_candidates",
"warehouse_id": "<あなたのウェアハウスID>", # ★ 自分の環境の値に置き換える
},
pip_requirements=[
"mlflow[databricks]>=3.9.0,<3.12",
"databricks-langchain>=0.20",
"databricks-sdk>=0.30",
"langgraph>=1.2.0",
"langchain>=1.2.0",
"langchain-core>=1.2.0",
"pandas",
],
resources=resources,
registered_model_name=UC_MODEL,
input_example={"messages": [{"role": "user", "content": "スコアの高い候補は?"}]},
)
print("model_uri:", logged.model_uri)
print("version:", logged.registered_model_version)
このセルには、デプロイを成功させるための要素が凝縮されています。
-
python_modelにファイルパスを渡す(Models from Code)。オブジェクトを pickle する
のではなく、コードそのものをモデルとして保存する方式です -
code_pathsで依存モジュールを同梱。chat_agent.pyはscreening_agent.pyを
import するので、一緒に運ばないとコンテナで動きません -
model_configでプロンプトを焼き込む。@productionから解決したテキストがここに入り、
ラッパー側はModelConfigから読むだけになります -
resourcesで自動認証パススルーの対象を宣言。LLM エンドポイントと SQL ウェアハウス、
そして(対応していれば)参照するビュー。ただしこれだけでは足りません——理由はこの後の
コラムで説明します。DatabricksTableをtryで囲んでいるのは mlflow のバージョン差の吸収です -
pip_requirementsを明示。コンテナに入れるライブラリを固定します -
registered_model_nameで UC に登録。stock_analysis.agents.screening_agentとして、
モデルもカタログのガバナンス下に置かれます
from databricks import agents
# データ取得用の専用 SP 資格情報をシークレット参照で注入する。値そのものはノートブックにも
# モデルにも残らず、サービング実行時にコンテナの環境変数へ展開される。
deployment = agents.deploy(
UC_MODEL,
logged.registered_model_version,
scale_to_zero=True, # 無負荷時ゼロスケール(Free Edition のリソース節約)
environment_vars={
"DATABRICKS_CLIENT_ID": "{{secrets/stock_analysis_agent/client_id}}",
"DATABRICKS_CLIENT_SECRET": "{{secrets/stock_analysis_agent/client_secret}}",
},
)
print(deployment)
from databricks.agents import get_deployments
for d in get_deployments(UC_MODEL):
print("エンドポイント:", d.endpoint_name)
print("Review App URL:", getattr(d, "review_app_url", "(Serving タブから開けます)"))
print("\nエンドポイントの READY 化には数分〜十数分かかります。")
print("Serving タブ、または `databricks serving-endpoints query <name>` で確認してください。")
scale_to_zero=True は、無負荷のときにコンピュートを0まで落とす設定です。Free Edition では
必須級ですが、代償としてコールドスタートが発生します。手元の実測では、
眠っていたエンドポイントへの初回リクエストが約68秒、ウォーム状態では2.6〜7.1秒でした。
CLI のデフォルトタイムアウト(60秒)に初回だけ引っかかることがあります。
デプロイされたエンドポイント名は agents_stock_analysis-agents-screening_agent のように、
UC のモデル名から機械的に決まります。
コラム:プロンプトを「いつ」読むか
本記事の実装は、@production をデプロイ時に解決してモデルへ焼き込む方式(案①)です。
もう1つ、リクエストのたびに @production を読む方式(案②)もあります。
| 案①:デプロイ時に読む(本記事) | 案②:リクエスト毎に読む | |
|---|---|---|
| プロンプト差し替え | 再デプロイが必要 | エイリアスの付け替えだけ(再デプロイ不要) |
| サービング側に要る権限 | 不要(プロンプトは焼き込み済み) |
prompts スキーマの MANAGE など |
| 障害の切り分け | モデルに固定されているので再現しやすい | 実行時の外部依存が1つ増える |
LLMOps の見せ場としては案②の方が鮮やかです。エイリアスを付け替えるだけで本番の
プロンプトが切り替わる——モデルを触らずに振る舞いを変えられます。
それでも本記事が案①を採るのは、サービング時の外部依存を減らしたいからです。プロンプトを
モデルに焼き込んでしまえば、実行時に触るのは LLM エンドポイントと候補データだけになります。
依存が少ないほど、壊れたときに原因が絞れます。
「できること」と「今の自分に必要なこと」は違う。 必要になったときに案②へ移れるよう、
プロンプトを UC に置くところまでは済ませてある——という設計です。
コラム:権限が「通っているはず」で通らないとき
ステップ4で SP を作ったのは、筆者が実際に本番を止めたからです。この失敗は、
Databricks の権限モデルを理解する良い教材なので、そのまま共有します。
デプロイ直後は動いていました。ところが3週間後、同じエージェントに同じ質問をしたら
全リクエストが落ちるようになっていました。コードは1行も触っていません。
PermissionDenied: This API is disabled for users without the
databricks-sql-access or workspace-consume entitlements.
奇妙なのは、自分の手元では同じ SQL が普通に通ることです。ワークスペース管理者なので
当然です。では、落ちているのは誰なのか。
誰がエージェントを動かしているのか
ノートブックを手で実行するときは、あなたの権限で動きます。しかしサービングエンドポイントは、
あなたがログインしていなくてもリクエストを待ち受けます。あなたが居ない状態でデータを
取りに行くため、Databricks は専用の実行アカウントを用意します。
デプロイ時に Databricks が行うことを、公式ドキュメント(Model Serving のエージェント認証)は
次のように説明しています。要約すると、(1) エンドポイントの作成者が、モデルのログ時に宣言した
依存先すべてにアクセスできるかを検証し、(2) そのモデルバージョン用のサービスプリンシパル
を作成して、宣言されたリソースへの読み取り権限を自動で付け、(3) 短命の OAuth トークンを
エンドポイントに注入してローテーションする、という流れです。
つまり agents.deploy() を呼んだ時点で、あなたとは別のアカウントが裏で作られている
わけです。落ちていたのはそちらでした。
なお、公式ドキュメントが列挙しているのはリソース単位の権限(ウェアハウスの Use Endpoint、
テーブルの SELECT など)で、databricks-sql-access のようなエンタイトルメントには
触れていません。「エンタイトルメントは渡らない」というのは、このあと見る障害の調査
(2026-08-15)で筆者が確かめた結論です。
宣言したのに、なぜ足りなかったのか
log_model(resources=[...]) に SQL ウェアハウスもテーブルも宣言してあります。それでも
落ちました。ここに2つの層があります。
| 層 | 何を決めるか | 例 |
|---|---|---|
| エンタイトルメント | そもそもその機能を使ってよいか | databricks-sql-access |
| オブジェクト権限 | どのリソースを触ってよいか | ウェアハウスの CAN_USE、テーブルの SELECT
|
自動認証パススルーが渡すのは、下の層だけです。 DatabricksSQLWarehouse を宣言すれば
「このウェアハウスを使ってよい」は下りますが、「SQL という機能を使ってよい」は付いてきません。
両方揃わないと Statement Execution API は動きません。
デプロイ直後に動いていたのは、自動生成されたアカウントがたまたまその資格を持っていた
からでした。プラットフォーム側の変更でそれが外れた瞬間、宣言では埋められない層が露出した——
というのが真相です。
教訓
自動生成されるアカウントは Databricks が所有・ローテーションする対象です。そこに手で
権限を付けて回るのは、運用として成立しません。自分で所有するアカウントを立てて、
必要な資格を自分で管理する。 ステップ4の事前準備は、そのための作業です。
もうひとつ。デプロイ前のスモークテストは、この種の障害を検出できません。 あのセルは
あなたの資格情報で走るからです。実行主体が違う以上、権限の確認はデプロイ後に実際に
叩くまでできない——これは仕組み上の限界として受け入れて、デプロイ後の疎通確認を
手順に組み込むしかありません。
まとめ
ステップ1:作る
- エージェント本体はノートブックではなくモジュール(
agents/screening_agent.py)。評価・
プロンプト登録・サービングの3か所から同じコードを使う -
mlflow.langchain.autolog()だけでトレースが取れる。ただし記録先(experiment)は
モジュールで決めない。「どう記録するか」はモジュール、「どこへ」は呼び出し側 -
Model Serving のコンテナには Spark が無い。リクエストスコープ(専用 SP で SQL ウェアハウス)・
spark.table()・既定認証の SQL ウェアハウスの3経路を、この優先順位で切り替える - SQL ウェアハウス経由では数値も文字列で返る。型情報から数値列を判定して変換する
- Genie との差はツールの限定。自由な SQL ではなく、パラメータの決まった関数2つだけを渡す。
@toolの docstring は、そのまま LLM への仕様書になる - 該当が無いときは空ではなく**「無い」という文章を返す**。沈黙は幻覚を招く
- システムプロンプトは設計図ではなくログ。捏造・過剰な絞り込み・逃げ・リスク未言及——
実測で見つかった崩れが、そのまま制約として積み上がっている
ステップ2:測る
- 最初に疎通確認。Free Edition では一覧に出ていても呼べないモデルがある
- Vibe Check は肌感を掴むためだけでなく、後続のスコアラーが使うトレースを作る工程
- スコアラーは機械で判定できるものは機械で(キーワード)、表現の揺れを許すものは
LLM ジャッジで(内容の妥当性・免責・ツール呼び出しの意味的な一致)。ジャッジは本体と別モデル - 評価データセットには、「答えないことを期待する」ケースを必ず入れる
-
register()は冪等ではない。登録済みかを先に確認する -
評価が改善を捉えた:
mentions_risk0 → 1.0、correctness0.4 → 0.8。人間が読むだけでは
気付きにくい「一覧回答のときだけリスクに触れない」傾向を、数値が拾い上げた
ステップ3:プロンプトを管理する
- プロンプトはバージョン管理の対象。UC の Prompt Registry に3レベル名で登録し、
@productionエイリアスで本番を指す(@latestは Databricks 非対応) -
register_prompt()は呼ぶたびに積まれるので、同一内容ならスキップして冪等化する - 登録したプロンプトは Catalog Explorer に出ない。検索 API で確認する
ステップ4:公開する
-
ChatAgentでラップして Model Serving へ。設定 → import の順序を守る(モジュールが
import 時に環境変数を読むため) -
デプロイ後にしか通らない経路を、デプロイ前に叩く(SQL 経路のスモークテスト)。
ただし潰せるのは経路とデータ形だけ。実行主体が違うので権限は潰せない -
log_modelは Models from Code(ファイルを渡す)+code_pathsで依存を同梱、
resourcesでリソース依存を宣言、model_configでプロンプトを焼き込む -
自動認証パススルーはオブジェクト権限を渡すが、エンタイトルメントは渡さない。
データ取得は自分で所有する SP で行い、資格情報はenvironment_varsの
シークレット参照で注入する -
scale_to_zero=Trueの代償はコールドスタート(実測: 初回約68秒、ウォーム 2.6〜7.1秒)
次章(可視化)へ: これで、同じ
v_candidatesに対して「Genie に聞く」「自分の
エージェントに聞く」の2つの出口が揃いました。最後の出口は、聞かずに眺めるための
ダッシュボードです。次章では AI/BI Dashboard を組み立て、週次で更新される候補リストを
一目で把握できる形にします。
第7章 可視化 — 聞かずに眺めるダッシュボード
ここまでで、gold.v_candidates に対する出口が2つできました。Genie に聞く(第5章)、
自分のエージェントに聞く(第6章)。どちらも問いを立てる必要があります。
しかし実際の運用では、まず「今週はどうなっているか」を眺めたいことの方が多いはずです。
候補は何件通ったのか、スコアの分布はどうか、どの業種が多いのか。質問を考える前に、
全体像が目に入ってほしい。それがダッシュボードの役割です。
この章では、v_candidates を使って AI/BI Dashboard を組み立てます。第1部第6章で
samples.bakehouse を題材に触ったものを、自分のスクリーニング結果で作り直す形になります。
この章のノートブック:
notebooks/07_dashboard.py(本章のコードは全文このノートブックと一致します)
カタログ規約: 参照はstock_analysis.gold.v_candidates(第5章で作ったコメント付きビューを再利用)
構成は第4章・第5章と同じ3部構成です。
- 各ウィジェット用の SQL クエリを実行・検証する(コード)
- GUI でダッシュボードを組み立てる(画面操作)
- 公開したダッシュボードを API 経由で取得し、構成を確認する(コード)
SQL を先に検証してから GUI に貼る——第1部第6章で書いた「崩れないダッシュボードのコツ」を、
ここでもそのまま踏襲します。
ビューを作り直さない
この章では、新しいテーブルもビューも作りません。第5章で作った v_candidates をそのまま
使います。
第5章で v_candidates を「Genie とダッシュボードの両方が参照する共通のコメント付きビュー」
として設計したのは、このためでした。比率は _pct で % 表記に揃っていて、列は22に絞られ、
ai_comment の NULL は — に潰してある。Genie のために整えたものが、そのまま表示にも
効くわけです。
もし Genie 用とダッシュボード用に別々のビューを作っていたら、片方だけ列を足して表示が
食い違う、といった事故が起きます。出口が増えても、参照先は1つ。
ウィジェット用の SQL を先に検証する
ダッシュボードに載せる6つのクエリを、まとめて実行して確かめます。
from pyspark.sql import SparkSession
spark = SparkSession.builder.getOrCreate()
QUERIES = {
'kpi_summary': """
SELECT COUNT(*) AS total_candidates, ROUND(AVG(score), 1) AS avg_score, MAX(score) AS top_score
FROM stock_analysis.gold.v_candidates
""",
'candidates_ranking': """
SELECT rank, code, name, market_segment, industry_33,
current_price, per, pbr, roe_pct, gross_margin_pct, fcf_margin_pct, score, ai_comment
FROM stock_analysis.gold.v_candidates
WHERE score >= 60
ORDER BY rank
LIMIT 50
""",
'industry_summary': """
SELECT industry_33, COUNT(*) AS candidate_count, ROUND(AVG(score), 1) AS avg_score
FROM stock_analysis.gold.v_candidates
GROUP BY industry_33
ORDER BY candidate_count DESC
""",
'score_distribution': """
SELECT score FROM stock_analysis.gold.v_candidates
""",
'quality_map': """
SELECT name, gross_margin_pct, fcf_margin_pct, score, current_price
FROM stock_analysis.gold.v_candidates
WHERE gross_margin_pct IS NOT NULL AND fcf_margin_pct IS NOT NULL
""",
'last_updated': """
SELECT MAX(screened_date) AS last_updated FROM stock_analysis.gold.v_candidates
""",
}
for name, sql in QUERIES.items():
df = spark.sql(sql).toPandas()
print(f'--- {name}: {len(df)} 行 ---')
print(df.head(3).to_string(index=False))
print()
筆者の環境での出力は次のとおりです(ai_comment が長いので、candidates_ranking などは
先頭の数行だけを示し、残りは省いています。件数は実行時期によって変わります)。
--- kpi_summary: 1 行 ---
total_candidates avg_score top_score
32 64.6 90
--- candidates_ranking: 19 行 ---
rank code name market_segment industry_33 current_price per pbr roe_pct ...
1 2121 MIXI プライム(内国株式) 情報・通信業 3750.0 11.5 1.29 10.9 ...
--- industry_summary: 9 行 ---
industry_33 candidate_count avg_score
情報・通信業 9 66.7
--- score_distribution: 32 行 ---
--- quality_map: 32 行 ---
--- last_updated: 1 行 ---
last_updated
2026-10-11
どのクエリも 0 行ではなく、score_distribution と quality_map は v_candidates の総件数と
同じ行数が返っています。0 行になっていたら、ビューか前段(第3章)を疑います。
辞書にまとめてループで回す形にしているのは、あとで GUI に貼り付ける単位と1対1に
するためです。ここで名前を決めておけば、データセット名としてそのまま使えます。
6つのクエリは、それぞれ違う「見え方」を担当します。
| クエリ | ウィジェット | 何が分かるか |
|---|---|---|
kpi_summary |
KPI カウンター×3 | 候補は何件か、平均・最高スコアはいくつか |
candidates_ranking |
テーブル | 上位候補の一覧(AI コメント付き) |
industry_summary |
棒グラフ | どの業種に候補が偏っているか |
score_distribution |
ヒストグラム | スコアの散らばり方 |
quality_map |
散布図 | 収益品質(粗利益率×FCFマージン)の位置関係 |
last_updated |
KPI カウンター | データがいつのものか |
6つの選び方
kpi_summary は「全体で何件か」の一行。ダッシュボードを開いて最初に目に入るべき数字です。
candidates_ranking は WHERE score >= 60 と LIMIT 50 で絞っています。全件を表に
出しても読めないので、見る価値のある範囲に切ります。ai_comment を含めているので、
表の中で第3章の AI コメントが読めます。
industry_summary で業種の偏りを見るのは、長期保有の観点では重要です。候補が特定の
業種に集中していれば、それは分散が効いていないというシグナルになります。
score_distribution は SELECT score だけの素朴なクエリです。ビン分割はダッシュボード側の
ヒストグラムに任せます。集計を SQL でやり切らず、可視化側の機能に委ねる判断です。
quality_map は散布図用で、IS NOT NULL を明示しています。今のデータでは
第3章のスクリーニングを通った銘柄だけが入っているので欠損は出ませんが、将来スクリーニング条件を
緩めたときに、欠損のある行が散布図に意図しない形で出ないよう、防御として明示しています。
第3章で「fillna() は合否をどちらに倒すかの意思表明」と書きましたが、表示のための欠損除去も
また意思表明です。
last_updated が地味ながら効きます。週次で更新されるダッシュボードは、止まっていても
見た目が同じです。「先週のデータをずっと見ていた」を防ぐために、更新日をウィジェットとして
常に画面に出しておきます。第4章で作った週次ジョブが動いているかどうかが、ここに現れます。
ダッシュボードを作る(GUI 手順)
-
左ナビゲーション「Dashboards」→「Create dashboard」
-
ダッシュボード名を設定。例:
東証スクリーニング候補ダッシュボード -
「Data」タブで新規データセットを6件追加し、前節の SQL をそれぞれ貼り付ける
(データセット名は下表の名前に合わせる):データセット名 用途 kpi_summaryKPIカウンター(候補銘柄数・平均スコア・最高スコア) candidates_ranking候補銘柄ランキングテーブル industry_summary業種別候補数(棒グラフ) score_distributionスコア分布(ヒストグラム) quality_map収益品質マップ(散布図) last_updated直近更新日時 -
SQL Warehouse を選択(Free Edition では Serverless SQL ウェアハウスを指定)
-
「Canvas」タブに戻り、以下のウィジェットを配置する:
-
KPIカウンター×3(
kpi_summaryから): 候補銘柄数(total_candidates) / 平均スコア(avg_score) / 最高スコア(top_score) -
テーブル(
candidates_rankingから): 全カラムを表示、rank昇順 -
棒グラフ(
industry_summaryから): X軸=industry_33、Y軸=candidate_count -
ヒストグラム(
score_distributionから):score列を選択(自動ビニング) -
散布図(
quality_mapから): X軸=gross_margin_pct、Y軸=fcf_margin_pct、色/サイズ=score -
KPIカウンター(
last_updatedから):last_updated(直近更新日)
-
KPIカウンター×3(
-
右上「Publish」でダッシュボードを公開する
-
公開後のブラウザURL(
.../dashboardsv3/<DASHBOARD_ID>)からDASHBOARD_IDをコピーする
SQL が用意できていれば、ここは本当にドラッグと設定だけです。第1部第6章で書いたとおり、
手を動かす量の大半は前節(SQL の検証)で終わっています。
Dashboard ID を設定する
from databricks.sdk import WorkspaceClient
DASHBOARD_ID = '<あなたのDashboard ID>' # ★ 自分の環境の値に置き換える
w = WorkspaceClient()
dashboard = w.lakeview.get(DASHBOARD_ID)
print(f'Dashboard名 : {dashboard.display_name}')
print(f'Warehouse ID: {dashboard.warehouse_id}')
print(f'状態 : {dashboard.lifecycle_state}')
AI/BI Dashboard は、SDK では lakeview という名前で扱います(Lakeview が
AI/BI Dashboard の内部名です)。出力はこうなります。
Dashboard名 : 東証スクリーニング候補ダッシュボード
Warehouse ID: <あなたのウェアハウスID>
状態 : LifecycleState.ACTIVE
lifecycle_state は列挙型なので、LifecycleState. を付けた形で表示されます。末尾が ACTIVE なら
公開済みです。第5章の Genie Agent と同じく、
ウェアハウスが紐づいているかも確認しておきます。ダッシュボードは開くたびに SQL を
実行するので、ウェアハウスが無ければ何も表示されません。
構成を検証する
ここが、この章のコード側の山場です。GUI で組み立てたものを、コードで読み返します。
import json
spec = json.loads(dashboard.serialized_dashboard)
dataset_names = {d['name'] for d in spec.get('datasets', [])}
pages = spec.get('pages', [])
widget_count = sum(len(p.get('layout', [])) for p in pages)
print(f'データセット数: {len(dataset_names)} 件 → {sorted(dataset_names)}')
print(f'ページ数 : {len(pages)} 件')
print(f'ウィジェット数: {widget_count} 件')
v_candidates_referenced = any(
'v_candidates' in ''.join(d.get('queryLines', []) or [])
for d in spec.get('datasets', [])
)
print(f'v_candidates 参照 : {v_candidates_referenced}')
ダッシュボードの中身は JSON です。 serialized_dashboard に、データセットの定義
(SQL を含む)とページ・ウィジェットのレイアウトが丸ごと入っています。画面でぽちぽち
作ったものが、実は構造化されたテキストとして取り出せる——これを知っていると、
「本当に意図した SQL が入っているか」をコードで確認できます。
v_candidates_referenced の判定がまさにそれです。どのデータセットも v_candidates を
参照していなければ、このダッシュボードは設計から外れている。第5章の Genie 検証で
「生成 SQL が v_candidates を参照しているか」を見たのと、まったく同じ考え方です。
queryLines は SQL が行ごとの配列で入っているので、''.join(...) で連結してから
検索しています。
筆者の環境での出力は次のとおりです。
データセット数: 6 件 → ['31053fd1', '5b4edc9b', '6c5de18e', '72814918', '77e1d62d', 'd58589e3']
ページ数 : 1 件
ウィジェット数: 8 件
v_candidates 参照 : True
データセット名がハッシュになる
出力を見て「あれ?」と思ったはずです。GUI では kpi_summary のような名前を付けたのに、
name は 5b4edc9b のようなハッシュになっています。
これは、GUI で作ったデータセットの name が内部 ID で、画面に表示していた名前は
別のフィールド(displayName)に入っているためです。serialized_dashboard を覗くと、
両方が確認できます。
name(内部ID) |
displayName(画面の名前) |
|---|---|
5b4edc9b |
kpi_summary |
31053fd1 |
candidates_ranking |
72814918 |
industry_summary |
d58589e3 |
score_distribution |
77e1d62d |
quality_map |
6c5de18e |
last_updated |
「画面で見えている名前 = API で返る名前」とは限らない。 GUI で作ったものをコードで
検証するときに必ず出会う段差で、ここを知らないと「名前が化けている」と誤解します。
本章のコードが dataset_names を件数の確認にしか使っていないのは、この段差を
踏まえてのことです。中身まで見たいなら displayName を読みます。
ウィジェットが8件なのは、KPI カウンターを4つ(候補数・平均・最高・更新日)置いている
ためです。手順で数えると6種類ですが、KPI が複数枚に分かれるので実際の配置数は増えます。
完了確認
print('=== 完了確認 ===')
assert DASHBOARD_ID, 'DASHBOARD_ID が未設定(GUIで作成したDashboardのIDを貼り付けること)'
assert dashboard.warehouse_id, 'DashboardにWarehouseが紐づいていない'
assert v_candidates_referenced, 'どのデータセットもv_candidatesを参照していない'
assert widget_count >= 5, f'ウィジェット数が不足({widget_count}件、5件以上を推奨)'
score60_count = spark.sql(
'SELECT COUNT(*) AS cnt FROM stock_analysis.gold.v_candidates WHERE score >= 60'
).collect()[0]['cnt']
print(f'スコア60点以上の銘柄数: {score60_count} 件')
assert score60_count >= 1, 'スコア60点以上の銘柄が1件も存在しない'
print('\n✅ v_candidates を参照するAI/BI Dashboardが作成され、公開されていることを確認')
print(f'✅ ウィジェット数: {widget_count} 件、スコア60点以上の銘柄: {score60_count} 件')
print('\n次のステップ → 週次ジョブの実行後、最新の screened_date が反映されているか確認する')
4つの assert は、箱と中身の両方を確かめています。
- ID が設定されているか(貼り忘れの検出)
- ウェアハウスが紐づいているか(箱が動くか)
-
v_candidatesを参照しているか(正しい中身を見ているか) - ウィジェットが5件以上あるか(作りかけでないか)
最後に、score >= 60 の銘柄が1件以上あるかを確認しています。これはダッシュボードの
チェックというより、表示する中身が空でないかの確認です。テーブルウィジェットは
WHERE score >= 60 で絞っているので、ここが0件ならダッシュボードは正常なのに真っ白に
なります。「箱は正しいのに何も映らない」という、いちばん気付きにくい状態を潰しています。
筆者の環境ではスコア60点以上が 19件、ウィジェットは 8件でした(件数は実行時期によって変わります)。
コラム:3つの出口を使い分ける
これで v_candidates に対する出口が3つ揃いました。同じデータに3つの窓が開いている状態です。
| 出口 | 向いていること | 向かないこと |
|---|---|---|
| ダッシュボード(第7章) | 決まった指標を毎回同じ形で眺める。異常や変化に気付く | その場で思いついた条件で切り直す |
| Genie(第5章) | 思いついた問いをその場で試す。探索 | 品質の保証、決まった様式での回答 |
| 独自エージェント(第6章) | 制約を効かせた回答。評価して改善できる | 想定外の問い、自由な集計 |
大事なのは、どれが優れているかではなく、どれが今の目的に合うかです。週次の確認は
ダッシュボード、気になったら Genie で掘り、長期保有の所見が欲しいときはエージェントに聞く。
そしてこの3つはすべて、同じ v_candidates を見ています。第5章で「出口が増えても
参照先は1つ」と決めたことが、ここで効いてきます。もし3つがそれぞれ別のテーブルを見ていたら、
「ダッシュボードとエージェントで数字が違う」という、いちばん厄介な不整合が生まれていました。
まとめ
- ダッシュボードは問いを立てずに眺めるための出口。新しいビューは作らず、第5章の
v_candidatesをそのまま使う(出口が増えても参照先は1つ) -
SQL を先に検証してから GUI に貼る。辞書にまとめてループで確認し、キーをそのまま
データセット名にする - ウィジェットは「全体の数(KPI)→ 一覧(テーブル)→ 偏り(棒)→ 散らばり(ヒスト)→
位置関係(散布図)」の順で組み立て、最後にlast_updated(データの鮮度)を必ず置く。
週次更新のダッシュボードは止まっていても見た目が同じだから - 集計を SQL でやり切らず、ビン分割などは可視化側に委ねる(
score_distribution) - 散布図には
IS NOT NULLを明示する。今は欠損が出なくても、条件を緩めたときの防御になる - ダッシュボードの中身は JSON(
serialized_dashboard) として取り出せる。GUI で作った
ものをコードで読み返して検証できる -
nameは内部 ID、画面の名前はdisplayName。GUI 産の資産を API で見るときの段差 -
assertは**箱(ウェアハウス・ウィジェット数)と中身(v_candidates参照・データが空でないか)**の
両方を見る。「箱は正しいのに何も映らない」がいちばん気付きにくい
次章(終章)へ: Bronze → Silver → Gold のパイプラインを作り、週次で自動実行し、
Genie・独自エージェント・ダッシュボードという3つの出口を用意しました。次章では、
ここまでに作ったものを振り返り、この構成がなぜこの形に落ち着いたのか、そして
ここから先に何ができるのかを整理します。
終章 — 動くものを、継続的に良くする
ここまでで、東証上場株のスクリーニングシステムがひととおり動くようになりました。
最後に、作ったものを俯瞰し、第1部で学んだことがどこで効いたのかを振り返り、
ここから先に何ができるかを整理して締めたいと思います。
作ったもの
全体像はこうなりました。
器: stock_analysis カタログ + bronze / silver / gold スキーマ ── 第0章
[ JPX 銘柄マスタ ] [ yfinance 株価・財務 ]
│ │
▼ ▼
┌──────────────────────────────────────────┐
│ Bronze stock_master / stock_prices / │ 第1章
│ stock_financials(生データ) │
└──────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────┐
│ Silver stock_features(テクニカル指標) │ 第2章
└──────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────┐
│ Gold screening_results(通過+スコア │ 第3章
│ +AIコメント) │
│ v_candidates(コメント付きビュー) │ 第5章
└──────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
[ Genie ] [ 独自エージェント ] [ ダッシュボード ]
第5章 第6章 第7章
ぜんぶまとめて毎週日曜 7:00 に自動実行 ── 第4章
章ごとに何を作ったかを並べると、次のようになります。
| 章 | 作ったもの | この章の核心 |
|---|---|---|
| 0 | カタログとスキーマ | 器を先に作り、外部接続も先に確かめる。切り分けは実装前のほうが安い |
| 1 | Bronze 層 | 取れたものを取れたまま残す。整形は次の層で |
| 2 | Silver 層 | 銘柄ごと・日付順に指標を計算する。最新日スナップショットだけ持つ |
| 3 | Gold 層 | 絞る → 順位と言葉を付ける。長期保有の立場を配点に落とす |
| 4 | 週次ジョブ | 手放した先を確認できる仕組み(run_logs)とセットで自動化する |
| 5 | Genie Agent | 生テーブルを見せず、コメント付きビューだけを見せる |
| 6 | 独自エージェント | 限定ツール+トレース+評価。プロンプトを UC で管理し、サービングまで |
| 7 | ダッシュボード | 問わずに眺める出口。鮮度を常に画面に出す |
個人が1人で、Free Edition の範囲内で、ここまで組めます。
第1部が、第2部のどこで効いたか
第1部(基礎編)では、samples.bakehouse という架空のベーカリーを題材に、Databricks の
主要機能をひととおり触りました。あれが第2部でどう回収されたかを並べます。
| 第1部 | 第2部での回収 |
|---|---|
| 第2章 Spark — DataFrame API・遅延評価 | 東証全銘柄×3年分の株価を読み、toPandas() で pandas に渡す判断(第2章)。「分散で処理するもの」と「単一ノードで十分なもの」の線引き |
| 第3章 Delta Lake — ACID・タイムトラベル |
overwrite で作り直す Silver、append で積む run_logs(第4章)。VACUUM の保持期間がタイムトラベルの寿命を決めること |
| 第4章 Unity Catalog — 3階層・コメント・権限 |
stock_analysis.<schema>.<table> の徹底。列コメントが Genie の手がかりになる(第5章)。プロンプトも UC の3レベル名で管理(第6章) |
| 第5章 MLflow — トレーシング・評価 |
mlflow.langchain.autolog() によるエージェントの自動計装、LLM-as-a-judge による評価(第6章)。第1部で「何が記録されるか」を見ていたから、実戦のトレースが読める |
| 第6章 AI/BI・Genie — コメント付きビュー+GUI |
v_candidates を Genie とダッシュボードの共通レイヤーにする設計(第5章・第7章)。SQL を先に検証してから GUI に貼る手順 |
第1部で「これは何のためにあるのか」がピンとこなかった機能も、自分のデータに当てると
急に必要になります。列コメントなどはその典型で、第1部では「資産カタログとして検索・共有
できる文書」として紹介したものが、第2部では「生成 AI に意味を教えるため」という役割を持ちました。
通底していた5つの判断
章をまたいで、同じ考え方が繰り返し出てきました。振り返るとこの5つです。
1. 参照先のビューは1つ
Genie も、エージェントも、ダッシュボードも、全部 v_candidates を見ています。
出口ごとにビューを作らなかったことで、「ダッシュボードとエージェントで数字が違う」という
種類の事故が構造的に起きなくなりました。
出口が増えても、意味を定義する場所は1つ。 これは生成 AI を絡めるほど効きます。
表示のズレは目で気付けますが、AI の回答のズレは気付けないからです。
2. 作るのは GUI、確かめるのはコード
ジョブ(第4章)・Genie(第5章)・ダッシュボード(第7章)は、いずれも画面で作りました。
そのうえで、SDK や serialized_dashboard を使って構成をコードで読み返しています。
画面で作る手軽さは、Databricks の大きな価値です。一方、画面で作ったものは記録が残らず、
「意図どおりか」が曖昧になります。両方の良いところを取るのがこの組み合わせでした。
3. 冪等にする
Gold を読んで Gold に書くノートブック(第3章)、スコアラーの登録(第6章)、
プロンプトの登録(第6章)。いずれも2回目の実行で壊れる/積み上がる問題を抱えていて、
同じ発想——2回目の実行で前回の痕跡に壊されないよう、先に手当てしてから書く——を入れています
(第3章は再生成する列を先に落とし、第6章は登録済みなら再登録しません)。
ノートブックは何度も実行するものです。「1回目は通ったのに2回目で落ちる」は、
自動化した瞬間に牙をむきます。
4. 生成 AI は、出力ではなく経路を確かめる
Genie の検証では「答えが返った」で合格にせず、生成 SQL が v_candidates を参照して
いるかを見ました(第5章)。エージェントの評価では、期待したツールを呼んだかを
ToolCallCorrectness で見ました(第6章)。
もっともらしい答えは、間違った経路からも出てきます。それらしさは検証の材料にならない。
5. 「無い」と言えるようにする
ツールは該当0件のとき、空ではなく「含まれていません」という文章を返します(第6章)。
評価データセットには、存在しない証券コード9999を入れて、答えないことを期待しました。
Genie の Instructions には、「スクリーニングを通過した候補のみを含む」と母集団を宣言しました。
沈黙は幻覚を招きます。データに無いものを「無い」と言えることは、良い答えを返すことと
同じくらい重要な機能です。
制約がくれたもの
本記事は一貫して Databricks Free Edition で作りました。無料枠なので、当然いくつも
壁があります。
- 外部への通信に制限があり、HuggingFace の Inference API には届かない
- コンピュートのクォータがあり、全銘柄の収集は時間帯を選ぶ
- エンドポイント一覧に出ていても、実際には呼べないモデルがある
- サーバーレスの既定 Python は 3.11 で、
pandas-taが入らない - Model Serving のコンテナには Spark が無い
面倒には違いありませんが、この制約が設計を良くした面もありました。
外部 API が使えないから、ワークスペース内の Foundation Model API を使い、結果として
トークン管理が要らなくなりました。コストと時間がかかるから、AI コメントを上位30銘柄だけに
絞り、「全部に付ける必要があるのか」を考えました(第3章)。Spark が無いから、ツールのデータ取得を
2経路にし、どこで動くかを意識するようになりました。
制約は、何が本当に必要かを教えてくれます。 潤沢な環境で作っていたら、全銘柄に
AI コメントを付けて、コストと時間を溶かしていたかもしれません。
ここから先
作ったものは完成品ではなく、育てていく土台です。手を入れる余地を挙げておきます。
スコアリングを鍛える
配点(第3章)は、長期保有という立場から決めたひとつの仮説にすぎません。ここを改良する
方向はいくつもあります。
-
業種ごとに基準を変える。PER の適正水準は業種で大きく違うので、一律の閾値では
特定業種が有利になります - 配点の重みを見直す。収益品質30点という配分自体が仮説です
-
バックテストする。過去のある時点でスクリーニングしていたら、その後どうなったか。
Bronze に3年分の株価が残っているので、検証の材料はすでに手元にあります
3つ目が本命です。今のシステムは「候補を出す」までで、その候補が良かったのかを測る
仕組みがありません。評価の考え方(第6章)をスコアリングにも持ち込めれば、
「なんとなく良さそうな配点」から一歩進めます。
対象とデータを広げる
- 対象市場・銘柄数の拡大(Free Edition のクォータと相談しながら)
- 決算情報や適時開示など、別のデータソースの追加
- 為替や金利といったマクロ指標との突き合わせ
Bronze → Silver → Gold の骨格ができているので、入口を増やすのは難しくありません。
新しいソースを Bronze に足し、Silver で結合する、という定型作業になります。
エージェントを鍛える
第6章では扱わなかったものがあります。
-
Review App による人手フィードバックの収集。Review App は、デプロイしたエージェントを人に試して
もらい、回答に評価を付けてもらう画面です(第6章のデプロイ結果にも URL が出ていました)。
自動ジャッジは安く速い代わりに、
「本当にこの回答で嬉しいか」は測れません。人の評価を集めて、判定基準そのものを
鍛える方向があります -
プロンプトのリクエスト毎ロード(案②)。エイリアスを付け替えるだけで再デプロイなしに
本番プロンプトを切り替えられます。認証の手当てと引き換えです(第6章コラム) -
評価データセットの拡充。いまは5件です。運用の中で「うまく答えられなかった質問」を
拾ってデータセットに足していけば、評価は使うほど鋭くなります
3つ目がいちばん現実的で、効果も大きいはずです。失敗した質問こそ、次の評価データです。
運用を締める
- スクリーニング条件の定期的な見直し(3ヶ月ごと、第4章の運用メモ)
-
run_logsを使った異常検知——通過数が急に0件や極端な値になったら気付ける仕組み - ジョブ失敗時の通知
いずれも「作る」より「気付く」ための仕込みです。自動化したものは、放っておくと
静かに壊れます。
おわりに
本記事で作ったものは、投資判断の参考情報を生成するシステムであって、投資助言でも
推奨でもありません。エージェントのプロンプトに免責を必ず添えさせたのも、ai_comment の
生成に「与えた数値のみを根拠にする」という制約を課したのも、同じ理由からです。
最終的な判断は、必ず自分自身で行ってください。
そのうえで、このシステムの本当の価値は、出てきた銘柄リストそのものよりも、
「作って、測って、直す」というループが回る形で組めたことにあると思っています。
- パイプラインは週次で自動的に回る
- 実行の足跡は
run_logsに残る - エージェントの品質は評価の数値で追える
- プロンプトはバージョン管理され、いつでも戻せる
だから、思いついた改良をすぐ試せます。配点を変えて、スコアの分布がどう動くか見る。
プロンプトを直して、評価が上がるか測る。データソースを足して、候補がどう変わるか眺める。
動くものを作るのがスタートで、それを継続的に良くしていくのが本番です。
第1部で基礎を、第2部で1本の実装を通しました。ここから先は、あなた自身のテーマで、
同じループを回してみてください。
付録 — ソースコード全文とフォルダ構成
この付録は、本記事で作成した全コードファイルの完全なソースと、プロジェクトの
フォルダ構成をまとめた参考資料です。本文中では解説とともにコードを追いましたが、
ここでは「動くものを最初から最後まで通しで見たい」「手元に写経したい」というときの
リファレンスとして、省略なしのソースを収録します。
掲載しているコードは次のとおりです。
| 付録 | 対象フォルダ | 内容 | 対応 |
|---|---|---|---|
| 付録A | notebooks/ |
実践編ノートブック(10ファイル) | 第2部 |
| 付録B | agents/ |
LLMOpsエージェント モジュール(3ファイル) | 第2部 第6章 |
コード中に現れる完全修飾のテーブル名(
stock_analysis.bronze.stock_pricesなど)や
fundamentals.demo.*は Unity Catalog の3階層命名に従っています。詳しくは第1部 第4章を参照してください。
ワークスペースのフォルダ構成
本記事のノートブックとモジュールは、Databricks ワークスペース上に次の構成で配置します。
stock_analysis は import 先のフォルダ名で、任意の名前で構いません。
/Workspace/Users/<あなたのユーザー>/stock_analysis/
├── notebooks/ # 実践編(第2部)— 東証スクリーニング本体
│ ├── 00_setup.py # 環境セットアップ・外部接続確認
│ ├── 01_collect_data.py # データ収集 → bronze
│ ├── 02_screening.py # テクニカル指標計算(silver)・スクリーニング(gold)
│ ├── 03_scoring.py # ルールベーススコア + AIコメント
│ ├── 04_schedule_setup.py # 定期実行・運用設定(Workflowジョブ)
│ ├── 05_genie_setup.py # Genie Agent構築・検証
│ ├── 06a_llmops_agent.py # LLMOps独自エージェント
│ ├── 06b_prompt_registry.py # Prompt Registry(システムプロンプトをUCで管理)
│ ├── 06c_deploy_agent.py # エージェントのサービング公開
│ └── 07_dashboard.py # 可視化・補助
└── agents/ # LLMOpsエージェント用Pythonモジュール(Workspace Files)
├── __init__.py # パッケージ初期化
├── screening_agent.py # LangGraphベースのエージェント本体+限定ツール
└── chat_agent.py # ChatAgentラッパー(Model Serving用)
agents/ は notebooks/ と同じ階層(兄弟フォルダ)に置きます。ノートブックからは
sys.path に ../agents を追加して import します。
環境依存の値の書き換えかた
コードには、読者ごとに値が変わる箇所がいくつかあります。そのまま写経すると動かない
ため、掲載コードでは該当箇所を <...> のプレースホルダに置き換え、その行の行末に ★
を付けて示しています。★ が付いた行だけ自分の環境の値に書き換えてください。全コードを
通して 11 行だけです。
| 書き換える値 | 取得方法 | 出てくる場所(★ の行) |
|---|---|---|
<あなたのユーザー> |
Databricks のログインユーザー(画面右上のメールアドレス)。SELECT current_user() でも確認できます |
04_schedule_setup(完了確認)、06a(EXPERIMENT)、06b(author タグ)、06c(set_experiment)の 4 行 |
フォルダ名 stock_analysis
|
ノートブックを import したワークスペース上のフォルダ名。任意の名前で構いません(変えた場合は上記と同じ行を直す) | 上と同じ行に含まれます |
<あなたのウェアハウスID> |
SQL Warehouses 画面を開き、URL .../sql/warehouses/<ID> からコピー。Free Edition なら「Serverless Starter Warehouse」 |
06c の 3 行、agents/screening_agent.py、agents/chat_agent.py の計 5 行 |
<あなたのGenie Agent ID> |
Genie Agent を Publish 後、URL .../genie/rooms/<ID> からコピー |
05_genie_setup.py の 1 行 |
<あなたのDashboard ID> |
ダッシュボードを Publish 後、URL .../dashboardsv3/<ID> からコピー |
07_dashboard.py の 1 行 |
なお、stock_analysis.bronze.stock_prices のようなカタログ名や
databricks-meta-llama-3-3-70b-instruct のようなエンドポイント名は、どのワークスペースでも
共通に作れる/実在するため、書き換えは不要です。
ワークスペースパス
ノートブックのワークスペース上の絶対パスは、たとえば次のようになります。
/Workspace/Users/<あなたのユーザー>/stock_analysis/notebooks/01_collect_data
このうち読者ごとに変わるのは、上の表の <あなたのユーザー>(ログインユーザー)と
stock_analysis(import したフォルダ名。任意の名前で構いません)の 2 か所だけで、
/Workspace/Users/ と /notebooks/01_collect_data の部分は共通です。
パスを書くときは、先頭に /Workspace を付けるかどうかが用途で決まります。ここを
取り違えると「パスは合っているのに見つからない」という分かりにくいエラーになります。
| 用途 | 書きかた | 例 |
|---|---|---|
| MLflow の experiment パス |
/Workspace は 付けない
|
"/Users/<あなたのユーザー>/stock_analysis/llmops_agent" |
ワークスペースのファイル API(w.workspace.export() など) |
/Workspace から始める |
"/Workspace/Users/<あなたのユーザー>/stock_analysis/notebooks" |
ノートブックを実行すると、そのノートブックが置かれたワークスペース上のフォルダが
カレントディレクトリになります。os.getcwd()は
/Workspace/Users/<あなたのユーザー>/stock_analysis/notebooksを返すので、
ハードコードを避けたい場合はos.path.dirname(os.getcwd())から組み立てることもできます
(本記事では読み手にパス構造が見えるよう、あえてベタ書きで示しています)。
付録A — 実践編ノートブック(notebooks/)
実践編(第2部)で使う全ノートブックのソースコードです。Databricks 上では # COMMAND ---------- がセルの区切り、# MAGIC %md などが各セルのマジックコマンドを表します。掲載順はファイル名順(本記事の章順)です。
notebooks/00_setup.py
00_setup — 環境セットアップ・疎通確認
# Databricks notebook source
# MAGIC %md
# MAGIC # 00_setup — 環境セットアップ・疎通確認
# MAGIC
# MAGIC 東証株スクリーニングシステムの初期セットアップノートブックです。
# MAGIC
# MAGIC **実行順序:**
# MAGIC 1. ライブラリインストール確認
# MAGIC 2. Unity Catalogカタログ・スキーマ作成
# MAGIC 3. yfinance疎通テスト
# MAGIC 4. JPX銘柄マスタ取得テスト
# MAGIC 5. Databricks Foundation Model API疎通テスト
# COMMAND ----------
# MAGIC %md
# MAGIC ## 1. ライブラリインストール
# COMMAND ----------
# MAGIC %pip install yfinance pandas-ta requests openpyxl -q
# COMMAND ----------
import yfinance as yf
import pandas as pd
import pandas_ta as ta
import requests
from pyspark.sql import SparkSession
print(f"yfinance: {yf.__version__}")
print(f"pandas: {pd.__version__}")
print(f"pandas-ta: {ta.version}")
print("pyspark: OK")
print("\n✅ ライブラリインポート完了")
# COMMAND ----------
# MAGIC %md
# MAGIC ## 2. Unity Catalog カタログ・スキーマ作成
# COMMAND ----------
# カタログの確認
spark.sql("SHOW CATALOGS").show()
# COMMAND ----------
# MAGIC %sql
# MAGIC CREATE CATALOG IF NOT EXISTS stock_analysis
# MAGIC COMMENT '東証株スクリーニング(実践編)';
# MAGIC
# MAGIC CREATE SCHEMA IF NOT EXISTS stock_analysis.bronze
# MAGIC COMMENT 'Bronze layer - raw stock data (TSE)';
# MAGIC
# MAGIC CREATE SCHEMA IF NOT EXISTS stock_analysis.silver
# MAGIC COMMENT 'Silver layer - cleansed and feature-enriched data';
# MAGIC
# MAGIC CREATE SCHEMA IF NOT EXISTS stock_analysis.gold
# MAGIC COMMENT 'Gold layer - screening results and scores';
# COMMAND ----------
# スキーマ一覧確認
spark.sql("SHOW SCHEMAS IN stock_analysis").show()
# COMMAND ----------
# MAGIC %md
# MAGIC ## 3. yfinance 疎通テスト
# MAGIC
# MAGIC 代表銘柄(トヨタ: 7203.T)で株価取得を確認します。
# COMMAND ----------
try:
ticker = yf.Ticker("7203.T")
hist = ticker.history(period="5d")
if hist.empty:
print("⚠️ データが空です。Databricks Free Edition のアウトバウンド制限の可能性があります。")
print("→ 代替手段: ローカルPCでデータ収集 → Volumes 経由でアップロード")
else:
print(f"✅ yfinance 疎通OK: {len(hist)}行取得")
display(hist.tail())
except Exception as e:
print(f"❌ yfinance エラー: {e}")
print("→ 代替手段: ローカルPCでデータ収集 → Volumes 経由でアップロード")
# COMMAND ----------
# MAGIC %md
# MAGIC ## 4. JPX 銘柄マスタ取得テスト
# MAGIC
# MAGIC JPX公式の銘柄一覧(Excel)へのアクセスを確認します。
# COMMAND ----------
JPX_URL = "https://www.jpx.co.jp/markets/statistics-equities/misc/tvdivq0000001vg2-att/data_j.xlsx"
try:
response = requests.get(JPX_URL, timeout=30)
print(f"HTTPステータス: {response.status_code}")
if response.status_code == 200:
print(f"✅ JPX Excel ダウンロードOK: {len(response.content):,} bytes")
# 読み込みテスト
import io
df_test = pd.read_excel(io.BytesIO(response.content))
print(f" 行数: {len(df_test)}, 列数: {len(df_test.columns)}")
print(f" 列名: {df_test.columns.tolist()}")
else:
print(f"⚠️ HTTPエラー: {response.status_code}")
except Exception as e:
print(f"❌ JPX Excel 取得エラー: {e}")
print("→ 代替手段: ローカルPCでダウンロード → Volumes 経由でアップロード")
# COMMAND ----------
# MAGIC %md
# MAGIC ## 5. Databricks Foundation Model API 疎通テスト
# MAGIC
# MAGIC HuggingFace Inference API は Free Edition のアウトバウンド制限で到達不可のため、
# MAGIC Databricks 内部の Foundation Model API を使用します。
# MAGIC
# MAGIC このワークスペースに実在する主なチャット系モデル(2026-07-21 実測):
# MAGIC - `databricks-meta-llama-3-3-70b-instruct`(AIコメント生成・エージェント本体で使用)
# MAGIC - `databricks-meta-llama-3-1-8b-instruct`(軽量。日本語生成は品質不足)
# MAGIC - `databricks-llama-4-maverick`(LLMジャッジ用)
# MAGIC
# MAGIC ※ 旧記載の `databricks-meta-llama-3-1-70b-instruct` と `databricks-mixtral-8x7b-instruct` は存在しない。
# COMMAND ----------
import mlflow.deployments
# 本番(03_scoring・エージェント)で実際に使う 70B の疎通を確認する。
FMAPI_MODEL = "databricks-meta-llama-3-3-70b-instruct"
try:
client = mlflow.deployments.get_deploy_client("databricks")
response = client.predict(
endpoint=FMAPI_MODEL,
inputs={"messages": [{"role": "user", "content": "Hello! Reply in one word."}], "max_tokens": 20}
)
reply = response["choices"][0]["message"]["content"]
print(f"✅ Databricks Foundation Model API 疎通OK")
print(f" モデル: {FMAPI_MODEL}")
print(f" レスポンス: {reply}")
except Exception as e:
print(f"❌ Foundation Model API エラー: {e}")
print("→ モデル名を確認するか、別のエンドポイントを試してください")
# COMMAND ----------
# MAGIC %md
# MAGIC ## セットアップ完了チェック
# COMMAND ----------
print("=" * 50)
print("セットアップ 完了チェック")
print("=" * 50)
checks = {
"ライブラリインストール": None,
"Unity Catalog スキーマ (bronze/silver/gold)": None,
"yfinance 疎通": None,
"JPX Excel 取得": None,
"Databricks Foundation Model API 疎通": None,
}
try:
import yfinance, pandas, pandas_ta, requests # この4つが import できて初めてインストール成功
checks["ライブラリインストール"] = True
except Exception:
checks["ライブラリインストール"] = False
try:
schemas = [r.databaseName for r in spark.sql("SHOW SCHEMAS IN stock_analysis").collect()]
checks["Unity Catalog スキーマ (bronze/silver/gold)"] = all(s in schemas for s in ["bronze", "silver", "gold"])
except Exception:
checks["Unity Catalog スキーマ (bronze/silver/gold)"] = False
try:
h = yf.Ticker("7203.T").history(period="1d")
checks["yfinance 疎通"] = not h.empty
except Exception:
checks["yfinance 疎通"] = False
try:
import io
r = requests.get(JPX_URL, timeout=15)
pd.read_excel(io.BytesIO(r.content)) # ダウンロードだけでなく「読めるか」まで見る
checks["JPX Excel 取得"] = (r.status_code == 200)
except Exception:
checks["JPX Excel 取得"] = False
try:
import mlflow.deployments
c = mlflow.deployments.get_deploy_client("databricks")
res = c.predict(
endpoint="databricks-meta-llama-3-3-70b-instruct",
inputs={"messages": [{"role": "user", "content": "Hi"}], "max_tokens": 5}
)
checks["Databricks Foundation Model API 疎通"] = True
except Exception:
checks["Databricks Foundation Model API 疎通"] = False
for item, result in checks.items():
status = "✅" if result is True else ("❌" if result is False else "⚠️ 未確認")
print(f"{status} {item}")
print("\n次のステップ: 01_collect_data.py を実行してください")
notebooks/01_collect_data.py
データ収集
# Databricks notebook source
# MAGIC %md
# MAGIC # データ収集
# MAGIC JPX銘柄マスタ・株価・財務データをDelta Tableに蓄積する
# COMMAND ----------
# MAGIC %pip install yfinance pandas-ta requests openpyxl -q
# COMMAND ----------
import requests
import pandas as pd
import yfinance as yf
import time
import io
from pyspark.sql import SparkSession
from pyspark.sql.types import StructType, StructField, StringType, DoubleType, LongType, TimestampType, DateType
from datetime import datetime
spark = SparkSession.builder.getOrCreate()
print('Libraries loaded OK')
# COMMAND ----------
# MAGIC %md
# MAGIC ## 1-1. JPX銘柄マスタ取得 → bronze.stock_master
# COMMAND ----------
JPX_URL = 'https://www.jpx.co.jp/markets/statistics-equities/misc/tvdivq0000001vg2-att/data_j.xlsx'
try:
resp = requests.get(JPX_URL, timeout=30)
resp.raise_for_status()
df_master = pd.read_excel(io.BytesIO(resp.content), engine='openpyxl')
print(f'JPX Excel取得成功: {df_master.shape}')
except Exception as e:
raise RuntimeError(f'JPX Excel取得失敗: {e}')
# 実際の列名を確認して10列にリネーム
# 実際の列: ['日付','コード','銘柄名','市場・商品区分','33業種コード','33業種区分','17業種コード','17業種区分','規模コード','規模区分']
n_cols = len(df_master.columns)
if n_cols >= 10:
df_master = df_master.iloc[:, :10]
df_master.columns = [
'date', 'code', 'name', 'market_segment',
'industry_33_code', 'industry_33',
'industry_17_code', 'industry_17',
'scale_index_code', 'scale_index'
]
else:
raise ValueError(f'想定外のカラム数: {n_cols}(期待値: 10)')
# 内国株式のプライム・スタンダード・グロースのみ抽出
# 実際の値例: 'プライム(内国株式)', 'スタンダード(内国株式)', 'グロース(内国株式)'
df_master = df_master[
df_master['market_segment'].astype(str).str.contains('内国株式', na=False)
& df_master['market_segment'].astype(str).str.contains('プライム|スタンダード|グロース', na=False)
].copy()
# 全カラムを文字列に統一してから retrieved_at を追加(型変換エラー回避)
for col in df_master.columns:
df_master[col] = df_master[col].astype(str)
df_master['retrieved_at'] = pd.Timestamp.now()
print(f'対象銘柄数: {len(df_master)}')
print(df_master['market_segment'].value_counts())
if len(df_master) == 0:
raise ValueError('銘柄マスタが0件')
spark_df = spark.createDataFrame(df_master)
spark_df.write.format('delta').mode('overwrite').option('overwriteSchema', 'true') \
.saveAsTable('stock_analysis.bronze.stock_master')
print(f'\n✅ stock_analysis.bronze.stock_master 保存完了: {len(df_master)}件')
# COMMAND ----------
# MAGIC %md
# MAGIC ## 1-2. 株価データ取得 → bronze.stock_prices
# COMMAND ----------
PRICE_PERIOD = '3y'
BATCH_SIZE = 500
SLEEP_SEC = 1
tickers = [f"{code}.T" for code in df_master['code'].tolist()]
print(f'取得対象: {len(tickers)}銘柄')
all_prices = []
failed_price = []
batches = [tickers[i:i+BATCH_SIZE] for i in range(0, len(tickers), BATCH_SIZE)]
for batch_idx, batch in enumerate(batches):
print(f'バッチ {batch_idx+1}/{len(batches)} 処理中...')
for ticker_str in batch:
try:
ticker_obj = yf.Ticker(ticker_str)
hist = ticker_obj.history(period=PRICE_PERIOD)
if hist.empty:
failed_price.append(ticker_str)
continue
hist = hist.reset_index()
hist['ticker'] = ticker_str
hist['code'] = ticker_str.replace('.T', '')
# カラム名を小文字・スペースをアンダースコアに
hist.columns = [c.lower().replace(' ', '_') for c in hist.columns]
# Date列をdatetimeに統一
if 'date' in hist.columns:
hist['date'] = pd.to_datetime(hist['date']).dt.tz_localize(None)
all_prices.append(hist)
except Exception as e:
failed_price.append(ticker_str)
time.sleep(SLEEP_SEC)
print(f'\n取得成功: {len(all_prices)}銘柄 / 失敗: {len(failed_price)}銘柄')
if failed_price:
print(f'失敗銘柄(先頭20件): {failed_price[:20]}')
df_prices = pd.concat(all_prices, ignore_index=True)
print(f'株価レコード数: {len(df_prices):,}')
spark_df = spark.createDataFrame(df_prices)
spark_df.write.format('delta').mode('overwrite').option('overwriteSchema', 'true') \
.saveAsTable('stock_analysis.bronze.stock_prices')
print(f'\n✅ stock_analysis.bronze.stock_prices 保存完了: {len(df_prices):,}レコード')
# COMMAND ----------
# MAGIC %md
# MAGIC ## 1-3. 財務データ取得 → bronze.stock_financials
# COMMAND ----------
def _to_float(v):
try:
f = float(v)
return f if f != 0.0 else None
except (TypeError, ValueError):
return None
def fetch_financial_info(ticker_str: str, max_retries: int = 3) -> dict | None:
"""リトライロジック付きで yfinance から財務情報を取得する"""
for attempt in range(max_retries):
try:
info = yf.Ticker(ticker_str).info
if not info or info.get('quoteType') not in ('EQUITY', None):
return None
return {
'code': ticker_str.replace('.T', ''),
'ticker': ticker_str,
'market_cap': _to_float(info.get('marketCap')),
'per': _to_float(info.get('trailingPE')),
'pbr': _to_float(info.get('priceToBook')),
'roe': _to_float(info.get('returnOnEquity')),
'roa': _to_float(info.get('returnOnAssets')),
'revenue_growth': _to_float(info.get('revenueGrowth')),
'earnings_growth': _to_float(info.get('earningsGrowth')),
'debt_to_equity': _to_float(info.get('debtToEquity')),
'current_ratio': _to_float(info.get('currentRatio')),
'dividend_yield': _to_float(info.get('dividendYield')),
'operating_margin': _to_float(info.get('operatingMargins')),
'profit_margin': _to_float(info.get('profitMargins')),
'gross_margin': _to_float(info.get('grossMargins')),
'free_cashflow': _to_float(info.get('freeCashflow')),
'total_revenue': _to_float(info.get('totalRevenue')),
'enterprise_to_ebitda': _to_float(info.get('enterpriseToEbitda')),
'retrieved_at': pd.Timestamp.now(),
}
except Exception:
if attempt < max_retries - 1:
time.sleep(2 ** attempt)
else:
return None
return None
# ── 初回バッチ取得 ──────────────────────────────────────────────────
BATCH_SIZE_FIN = 500
SLEEP_SEC_FIN = 1
financial_records = []
failed_fin = []
batches_fin = [tickers[i:i+BATCH_SIZE_FIN] for i in range(0, len(tickers), BATCH_SIZE_FIN)]
for batch_idx, batch in enumerate(batches_fin):
print(f'財務バッチ {batch_idx+1}/{len(batches_fin)} 処理中...')
for ticker_str in batch:
result = fetch_financial_info(ticker_str)
if result is not None:
financial_records.append(result)
else:
failed_fin.append(ticker_str)
time.sleep(SLEEP_SEC_FIN)
print(f'\n初回取得: 成功 {len(financial_records)}件 / 失敗 {len(failed_fin)}件')
# ── 失敗分リトライ ──────────────────────────────────────────────────
if failed_fin:
print(f'\n失敗分リトライ中... ({len(failed_fin)}件)')
RETRY_BATCH_SIZE = 200
RETRY_SLEEP = 2
still_failed = []
retry_batches = [failed_fin[i:i+RETRY_BATCH_SIZE] for i in range(0, len(failed_fin), RETRY_BATCH_SIZE)]
for batch_idx, batch in enumerate(retry_batches):
print(f' リトライバッチ {batch_idx+1}/{len(retry_batches)} ({len(batch)}銘柄)...')
for ticker_str in batch:
result = fetch_financial_info(ticker_str, max_retries=3)
if result is not None:
financial_records.append(result)
else:
still_failed.append(ticker_str)
time.sleep(RETRY_SLEEP)
recovered = len(failed_fin) - len(still_failed)
print(f'リトライ後: 追加取得 {recovered}件 / 最終失敗 {len(still_failed)}件')
if still_failed:
print(f'最終失敗銘柄(先頭20件): {[t.replace(".T","") for t in still_failed[:20]]}')
df_financials = pd.DataFrame(financial_records)
print(f'\n財務データ合計: {len(df_financials)}銘柄')
spark_df = spark.createDataFrame(df_financials)
spark_df.write.format('delta').mode('overwrite').option('overwriteSchema', 'true') \
.saveAsTable('stock_analysis.bronze.stock_financials')
print(f'\n✅ stock_analysis.bronze.stock_financials 保存完了: {len(df_financials)}件')
# COMMAND ----------
# MAGIC %md
# MAGIC ## 完了確認
# COMMAND ----------
print('=== 完了確認 ===')
master_cnt = spark.table('stock_analysis.bronze.stock_master').count()
prices_cnt = spark.table('stock_analysis.bronze.stock_prices').count()
fin_cnt = spark.table('stock_analysis.bronze.stock_financials').count()
print(f'stock_master : {master_cnt:>8,} 件')
print(f'stock_prices : {prices_cnt:>8,} 件')
print(f'stock_financials: {fin_cnt:>8,} 件')
assert master_cnt >= 1000, f'stock_master が1000件未満: {master_cnt}'
assert prices_cnt > 0, 'stock_prices が空'
assert fin_cnt > 0, 'stock_financials が空'
print('\n✅ 全チェック通過')
# COMMAND ----------
# 実行ログ記録(明示的スキーマで None 型推論エラーを回避)
from pyspark.sql.types import StructType, StructField, StringType, IntegerType, DoubleType
log_schema = StructType([
StructField('run_date', StringType(), True),
StructField('phase', StringType(), True),
StructField('record_count', IntegerType(), True), # そのフェーズの件数(collect: 収集成功銘柄数)
StructField('top_score', DoubleType(), True),
StructField('status', StringType(), True),
])
log_record = [(
pd.Timestamp.now().isoformat(),
'collect',
int(len(all_prices)),
None,
'success',
)]
spark.createDataFrame(log_record, schema=log_schema) \
.write.format('delta').mode('append') \
.option('mergeSchema', 'true') \
.saveAsTable('stock_analysis.gold.run_logs')
print('✅ run_logs 記録完了')
notebooks/02_screening.py
スクリーニング実装
# Databricks notebook source
# MAGIC %md
# MAGIC # スクリーニング実装
# MAGIC テクニカル指標計算(silver層)・ファンダメンタル+テクニカルスクリーニング(gold層)
# COMMAND ----------
# MAGIC %pip install pandas-ta -q
# COMMAND ----------
import pandas as pd
import pandas_ta as ta
from pyspark.sql import SparkSession
spark = SparkSession.builder.getOrCreate()
print('Libraries loaded OK')
# COMMAND ----------
# MAGIC %md
# MAGIC ## 2-1. テクニカル指標計算 → silver.stock_features
# COMMAND ----------
print('bronze.stock_prices 読み込み中...')
df_prices = spark.table('stock_analysis.bronze.stock_prices').toPandas()
print(f'株価レコード数: {len(df_prices):,}')
df_prices['date'] = pd.to_datetime(df_prices['date'])
df_prices = df_prices.sort_values(['code', 'date'])
# COMMAND ----------
result_list = []
total = df_prices['code'].nunique()
for i, (code, group) in enumerate(df_prices.groupby('code')):
g = group.sort_values('date').copy()
g['ma25'] = ta.sma(g['close'], length=25)
g['ma75'] = ta.sma(g['close'], length=75)
g['ma200'] = ta.sma(g['close'], length=200)
g['rsi14'] = ta.rsi(g['close'], length=14)
macd_df = ta.macd(g['close'])
if macd_df is not None:
macd_col = next((c for c in macd_df.columns if c.startswith('MACD_')), None)
macdh_col = next((c for c in macd_df.columns if c.startswith('MACDh_')), None)
g['macd'] = macd_df[macd_col] if macd_col else None
g['macd_hist'] = macd_df[macdh_col] if macdh_col else None
else:
g['macd'] = None
g['macd_hist'] = None
bb = ta.bbands(g['close'], length=20)
if bb is not None:
bbu_col = next((c for c in bb.columns if c.startswith('BBU')), None)
bbl_col = next((c for c in bb.columns if c.startswith('BBL')), None)
g['bb_upper'] = bb[bbu_col] if bbu_col else None
g['bb_lower'] = bb[bbl_col] if bbl_col else None
else:
g['bb_upper'] = None
g['bb_lower'] = None
g['vol_ma20'] = g['volume'].rolling(20).mean()
g['price_mom_12m'] = g['close'].pct_change(252)
result_list.append(g)
if (i + 1) % 500 == 0:
print(f' {i+1}/{total} 銘柄処理済み')
df_features = pd.concat(result_list, ignore_index=True)
print(f'テクニカル指標計算完了: {len(df_features):,} レコード')
# COMMAND ----------
# 最新日スナップショットのみ silver に保存
latest_date = df_features['date'].max()
df_silver = df_features[df_features['date'] == latest_date].copy()
print(f'最新日付: {latest_date.date()} 対象銘柄数: {len(df_silver)}')
spark.createDataFrame(df_silver).write.format('delta').mode('overwrite') \
.option('overwriteSchema', 'true') \
.saveAsTable('stock_analysis.silver.stock_features')
print(f'\n✅ stock_analysis.silver.stock_features 保存完了: {len(df_silver)} 件')
# COMMAND ----------
# MAGIC %md
# MAGIC ## 2-2. スクリーニング実行 → gold.screening_results
# COMMAND ----------
df_tech = spark.table('stock_analysis.silver.stock_features').toPandas()
df_fund = spark.table('stock_analysis.bronze.stock_financials').toPandas()
df_master = spark.table('stock_analysis.bronze.stock_master').toPandas()
print(f'silver.stock_features : {len(df_tech):>6,} 件')
print(f'bronze.stock_financials: {len(df_fund):>6,} 件')
print(f'bronze.stock_master : {len(df_master):>6,} 件')
df = df_tech.merge(df_fund, on='code', how='inner')
df = df.merge(df_master[['code', 'name', 'market_segment', 'industry_33']], on='code', how='left')
print(f'\n結合後レコード数: {len(df):,}')
# COMMAND ----------
# FCF マージン計算
def _fcf_margin(row):
rev = row.get('total_revenue')
fcf = row.get('free_cashflow')
if rev and rev > 0 and fcf is not None:
return fcf / rev
return None
df['fcf_margin'] = df.apply(_fcf_margin, axis=1)
# ── スクリーニングパラメータ(変更容易にするため変数化) ─────────────
PER_MAX = 25.0
PBR_MAX = 3.0
ROE_MIN = 0.08
REVENUE_GROWTH_MIN = 0.00
EARNINGS_GROWTH_MIN = -0.10
OPERATING_MARGIN_MIN = 0.05
GROSS_MARGIN_MIN = 0.20
FCF_MIN = 0.0 # free_cashflow > 0
CURRENT_RATIO_MIN = 1.0
DE_MAX = 100.0
RSI_MIN = 30.0
RSI_MAX = 75.0
VOL_MA20_MIN = 100_000
# ── ファンダメンタルフィルタ ──────────────────────────────────────────
f_fundamental = (
(df['per'].between(0, PER_MAX)) &
(df['pbr'].between(0, PBR_MAX)) &
(df['roe'] >= ROE_MIN) &
(df['revenue_growth'] >= REVENUE_GROWTH_MIN) &
(df['earnings_growth'].fillna(0) >= EARNINGS_GROWTH_MIN) &
(df['operating_margin'] >= OPERATING_MARGIN_MIN) &
(df['gross_margin'].fillna(0) >= GROSS_MARGIN_MIN) &
(df['free_cashflow'].fillna(0) > FCF_MIN) &
(df['current_ratio'].fillna(0) >= CURRENT_RATIO_MIN) &
(df['debt_to_equity'].fillna(999) <= DE_MAX)
)
# ── テクニカルフィルタ ────────────────────────────────────────────────
f_technical = (
(df['close'] > df['ma200']) &
(df['ma25'] > df['ma75']) &
(df['rsi14'].between(RSI_MIN, RSI_MAX)) &
(df['vol_ma20'].fillna(0) >= VOL_MA20_MIN)
)
# ── フィルタ別通過状況 ─────────────────────────────────────────────
n_total = len(df)
n_fundamental = f_fundamental.sum()
n_technical = f_technical.sum()
n_both = (f_fundamental & f_technical).sum()
print(f'全銘柄数 : {n_total:,}')
print(f'ファンダメンタル通過: {n_fundamental:,} ({n_fundamental/n_total*100:.1f}%)')
print(f'テクニカル通過 : {n_technical:,} ({n_technical/n_total*100:.1f}%)')
print(f'両方通過(最終候補) : {n_both:,} ({n_both/n_total*100:.1f}%)')
# COMMAND ----------
df_screened = df[f_fundamental & f_technical].copy()
df_screened['screened_at'] = pd.Timestamp.now()
df_screened['passed_fundamental'] = True
df_screened['passed_technical'] = True
# 後段(03_scoring)が埋める列を、ここで空のまま確保しておく。
# gold は上書きのたびに全列を書き直すため、この4列を欠かすとテーブル側で
# データスキッピング統計の対象に選ばれている列が消え、書き込みが弾かれる。
df_screened['score'] = float('nan')
df_screened['rank'] = float('nan')
df_screened['ai_comment'] = '—'
df_screened['scored_at'] = pd.NaT
spark.createDataFrame(df_screened).write.format('delta').mode('overwrite') \
.option('overwriteSchema', 'true') \
.saveAsTable('stock_analysis.gold.screening_results')
print(f'\n✅ stock_analysis.gold.screening_results 保存完了: {len(df_screened)} 件')
print('\n--- スクリーニング通過銘柄(先頭20件)---')
print(
df_screened[['code', 'name', 'market_segment', 'industry_33',
'per', 'pbr', 'roe', 'revenue_growth', 'gross_margin']]
.head(20).to_string(index=False)
)
# COMMAND ----------
# MAGIC %md
# MAGIC ## 完了確認
# COMMAND ----------
print('=== 完了確認 ===')
silver_cnt = spark.table('stock_analysis.silver.stock_features').count()
gold_cnt = spark.table('stock_analysis.gold.screening_results').count()
total_codes = spark.table('stock_analysis.bronze.stock_master').count()
print(f'silver.stock_features : {silver_cnt:>6,} 件')
print(f'gold.screening_results : {gold_cnt:>6,} 件')
print(f'通過率 : {gold_cnt/total_codes*100:.1f}% ({gold_cnt}/{total_codes})')
assert silver_cnt > 0, 'silver.stock_features が空'
assert gold_cnt > 0, 'gold.screening_results が空(条件緩和を検討)'
assert gold_cnt <= total_codes, 'スクリーニング通過数が総銘柄数を超えている'
# price_mom_12m カラムの存在確認
cols = spark.table('stock_analysis.silver.stock_features').columns
assert 'price_mom_12m' in cols, 'price_mom_12m カラムが存在しない'
assert 'ma200' in cols, 'ma200 カラムが存在しない'
assert 'rsi14' in cols, 'rsi14 カラムが存在しない'
print('\n✅ 全チェック通過')
print(f'\n次のステップ → スコアリング実装(03_scoring.py)')
# COMMAND ----------
# 実行ログ記録(明示的スキーマで None 型推論エラーを回避)
from pyspark.sql.types import StructType, StructField, StringType, IntegerType, DoubleType
log_schema = StructType([
StructField('run_date', StringType(), True),
StructField('phase', StringType(), True),
StructField('record_count', IntegerType(), True), # そのフェーズの件数(screening: 通過銘柄数)
StructField('top_score', DoubleType(), True),
StructField('status', StringType(), True),
])
log_record = [(
pd.Timestamp.now().isoformat(),
'screening',
int(gold_cnt),
None,
'success',
)]
spark.createDataFrame(log_record, schema=log_schema) \
.write.format('delta').mode('append') \
.option('mergeSchema', 'true') \
.saveAsTable('stock_analysis.gold.run_logs')
print('✅ run_logs 記録完了')
notebooks/03_scoring.py
スコアリング実装
# Databricks notebook source
# MAGIC %md
# MAGIC # スコアリング実装
# MAGIC
# MAGIC スクリーニング通過銘柄にルールベーススコア(100点満点)を付与し、上位30銘柄に
# MAGIC Databricks Foundation Model API で長期保有目線のAIコメントを生成する(gold層更新)。
# COMMAND ----------
import pandas as pd
import time
from pyspark.sql import SparkSession
spark = SparkSession.builder.getOrCreate()
print('Libraries loaded OK')
# COMMAND ----------
# MAGIC %md
# MAGIC ## 3-A. ルールベーススコアリング(合計100点)
# COMMAND ----------
df_screened = spark.table('stock_analysis.gold.screening_results').toPandas()
# 冪等化: 本ノートブックが再生成する列を読み込み直後に落とす。
# gold を読み直す構成のため、これが無いと再実行時に ai_comment が merge で
# ai_comment_x/_y に分裂し KeyError になる(初回は列が無いので通るが2回目以降落ちる)。
df_screened = df_screened.drop(columns=['ai_comment', 'scored_at'], errors='ignore')
print(f'スクリーニング通過銘柄数: {len(df_screened):,}')
# COMMAND ----------
def calculate_score(row):
score = 0
# ── 収益品質 (30点) ──────────────────────────────────────────────
gross = row.get('gross_margin') or 0
if gross >= 0.50: score += 12
elif gross >= 0.40: score += 9
elif gross >= 0.30: score += 6
elif gross >= 0.20: score += 3
fcf_m = row.get('fcf_margin') or 0
if fcf_m >= 0.15: score += 10
elif fcf_m >= 0.10: score += 8
elif fcf_m >= 0.05: score += 5
elif fcf_m >= 0.00: score += 2
roa = row.get('roa') or 0
if roa >= 0.10: score += 8
elif roa >= 0.07: score += 6
elif roa >= 0.05: score += 4
elif roa >= 0.03: score += 2
# ── 成長性 (20点) ────────────────────────────────────────────────
rg = row.get('revenue_growth') or 0
if rg >= 0.20: score += 10
elif rg >= 0.10: score += 8
elif rg >= 0.05: score += 5
elif rg >= 0.00: score += 2
eg = row.get('earnings_growth') or 0
if eg >= 0.20: score += 10
elif eg >= 0.10: score += 8
elif eg >= 0.05: score += 5
elif eg >= 0.00: score += 2
# ── バリュエーション (20点) ──────────────────────────────────────
roe = row.get('roe') or 0
if roe >= 0.20: score += 8
elif roe >= 0.15: score += 6
elif roe >= 0.10: score += 4
elif roe >= 0.08: score += 2
per = row.get('per') or 99
if per <= 10: score += 7
elif per <= 15: score += 6
elif per <= 20: score += 4
elif per <= 25: score += 2
pbr = row.get('pbr') or 99
if pbr <= 1.0: score += 5
elif pbr <= 1.5: score += 4
elif pbr <= 2.0: score += 3
elif pbr <= 3.0: score += 1
# ── 財務健全性 (15点) ────────────────────────────────────────────
de = row.get('debt_to_equity') or 999
if de <= 30: score += 7
elif de <= 60: score += 5
elif de <= 80: score += 3
elif de <= 100: score += 1
cr = row.get('current_ratio') or 0
if cr >= 2.0: score += 5
elif cr >= 1.5: score += 4
elif cr >= 1.2: score += 3
elif cr >= 1.0: score += 2
ev_ebitda = row.get('enterprise_to_ebitda') or 999
if ev_ebitda <= 8: score += 3
elif ev_ebitda <= 12: score += 2
elif ev_ebitda <= 16: score += 1
# ── 長期トレンド (15点) ──────────────────────────────────────────
close = row.get('close') or 0
ma200 = row.get('ma200') or 1
trend = (close - ma200) / ma200 if ma200 > 0 else 0
if trend >= 0.20: score += 8
elif trend >= 0.10: score += 6
elif trend >= 0.05: score += 4
elif trend >= 0.00: score += 2
mom12 = row.get('price_mom_12m') or 0
if mom12 >= 0.20: score += 7
elif mom12 >= 0.10: score += 5
elif mom12 >= 0.00: score += 3
else: score += 1
return min(score, 100)
# COMMAND ----------
df_screened['score'] = df_screened.apply(calculate_score, axis=1)
df_screened['rank'] = df_screened['score'].rank(ascending=False, method='min').astype(int)
df_screened = df_screened.sort_values('score', ascending=False).reset_index(drop=True)
print(f'スコア計算完了: {len(df_screened)} 銘柄')
print(f'スコア分布: min={df_screened["score"].min()} max={df_screened["score"].max()} mean={df_screened["score"].mean():.1f}')
print('\n--- スコア上位10銘柄 ---')
print(df_screened[['rank', 'code', 'name', 'score']].head(10).to_string(index=False))
# COMMAND ----------
# MAGIC %md
# MAGIC ## 3-B. AIコメント生成(Databricks Foundation Model API・上位30銘柄)
# COMMAND ----------
import mlflow.deployments
TOP_N = 30
# AIコメント生成は 8B だと日本語が崩れ事実を捏造する(例: 実データと矛盾する「大幅売上減少」等)。
# 実在・疎通実績のある 70B に差し替え、接地された自然な日本語コメントを得る。
FMAPI_MODEL = 'databricks-meta-llama-3-3-70b-instruct'
client = mlflow.deployments.get_deploy_client('databricks')
def generate_ai_comment(row):
roe = row.get('roe', 0) or 0
roa = row.get('roa', 0) or 0
gross = row.get('gross_margin', 0) or 0
fcf_m = row.get('fcf_margin', 0) or 0
om = row.get('operating_margin', 0) or 0
rg = row.get('revenue_growth', 0) or 0
eg = row.get('earnings_growth', 0) or 0
de = row.get('debt_to_equity', 0) or 0
cr = row.get('current_ratio', 0) or 0
prompt = f"""以下の東証上場銘柄を、3〜5年の長期保有を前提に分析してください。
一時的な株価変動ではなく、ビジネスモデルの持続性・競争優位性・財務健全性を重視し、
3文以内の評価コメントを日本語で出力してください。
また、主なリスク要因(業種サイクル・債務・競合)があれば1文で触れてください。
必ず守ること:
- 下に示す数値と業種のみを根拠に述べる。具体的な製品名・地域・買収・社名変更・提携など、
与えられていない事実を創作しない(分からないことは述べない)。
- 数値と矛盾する主張をしない(例: 売上成長率がプラスなのに「売上減少」と書かない)。
銘柄名: {row['name']}(証券コード: {row['code']})
業種: {row['industry_33']}
ROE: {roe:.1%} ROA: {roa:.1%}
粗利益率: {gross:.1%} FCFマージン: {fcf_m:.1%} 営業利益率: {om:.1%}
売上成長率: {rg:.1%} 利益成長率: {eg:.1%}
PER: {row.get('per', 'N/A')} PBR: {row.get('pbr', 'N/A')}
D/E: {de:.0f} 流動比率: {cr:.1f}
スコア: {row['score']}/100
コメント:"""
try:
response = client.predict(
endpoint=FMAPI_MODEL,
inputs={'messages': [{'role': 'user', 'content': prompt}], 'max_tokens': 200}
)
return response['choices'][0]['message']['content'].strip()
except Exception as e:
return f'コメント生成失敗: {e}'
# COMMAND ----------
df_top = df_screened.head(TOP_N).copy()
print(f'AIコメント生成対象: 上位{TOP_N}銘柄')
comments = []
for i, (_, row) in enumerate(df_top.iterrows()):
comments.append(generate_ai_comment(row))
if (i + 1) % 10 == 0:
print(f' {i+1}/{TOP_N} 件生成済み')
time.sleep(1)
df_top['ai_comment'] = comments
print(f'✅ AIコメント生成完了: {len(df_top)} 件')
# COMMAND ----------
df_final = df_screened.merge(df_top[['code', 'ai_comment']], on='code', how='left')
df_final['ai_comment'] = df_final['ai_comment'].fillna('—')
df_final['scored_at'] = pd.Timestamp.now()
spark.createDataFrame(df_final).write.format('delta').mode('overwrite') \
.option('overwriteSchema', 'true') \
.saveAsTable('stock_analysis.gold.screening_results')
print(f'\n✅ stock_analysis.gold.screening_results 更新完了: {len(df_final)} 件(score/rank/ai_comment付与)')
print('\n--- スコア上位10銘柄(AIコメント付き) ---')
for _, row in df_final.sort_values('score', ascending=False).head(10).iterrows():
print(f"[{row['rank']}位 / {row['score']}点] {row['name']}({row['code']})")
print(f" {row['ai_comment']}\n")
# COMMAND ----------
# MAGIC %md
# MAGIC ## 完了確認
# COMMAND ----------
print('=== 完了確認 ===')
gold_tbl = spark.table('stock_analysis.gold.screening_results')
cols = gold_tbl.columns
gold_cnt = gold_tbl.count()
for c in ['score', 'rank', 'ai_comment', 'scored_at', 'fcf_margin']:
assert c in cols, f'{c} カラムが存在しない'
stats = gold_tbl.selectExpr('min(score) as min_s', 'max(score) as max_s').toPandas().iloc[0]
ai_comment_cnt = gold_tbl.filter("ai_comment IS NOT NULL AND ai_comment != '—'").count()
print(f'gold.screening_results : {gold_cnt:>6,} 件')
print(f'スコア範囲 : {stats["min_s"]:.0f} ~ {stats["max_s"]:.0f}')
print(f'AIコメント付与件数 : {ai_comment_cnt} 件')
assert stats['min_s'] is not None, 'score が計算されていない'
assert gold_cnt > 0, 'gold.screening_results が空'
assert ai_comment_cnt == min(30, gold_cnt), 'AIコメントが上位30銘柄分付与されていない'
print('\n✅ 全チェック通過')
print('\n次のステップ → 定期実行・運用設定(04_schedule_setup.py)')
# COMMAND ----------
# 実行ログ記録(明示的スキーマで None 型推論エラーを回避)
from pyspark.sql.types import StructType, StructField, StringType, IntegerType, DoubleType
log_schema = StructType([
StructField('run_date', StringType(), True),
StructField('phase', StringType(), True),
StructField('record_count', IntegerType(), True), # そのフェーズの件数(scoring: スコア付与銘柄数)
StructField('top_score', DoubleType(), True),
StructField('status', StringType(), True),
])
log_record = [(
pd.Timestamp.now().isoformat(),
'scoring',
int(gold_cnt),
float(stats['max_s']),
'success',
)]
spark.createDataFrame(log_record, schema=log_schema) \
.write.format('delta').mode('append') \
.option('mergeSchema', 'true') \
.saveAsTable('stock_analysis.gold.run_logs')
print('✅ run_logs 記録完了')
notebooks/04_schedule_setup.py
定期実行・運用設定
# Databricks notebook source
# MAGIC %md
# MAGIC # 定期実行・運用設定
# MAGIC
# MAGIC データ収集〜スコアリング(`01`→`02`→`03`)を **週次で自動実行** し、常に最新の候補リストを保つ。
# MAGIC
# MAGIC **Genie/ダッシュボードと同様、ジョブ本体は GUI
# MAGIC (Databricks Workflows = Jobs & Pipelines 画面)で作成する。** DABs / API / CLI は使わない。
# MAGIC 「画面の設定だけで週次パイプラインが立つ手軽さ」をそのまま技術書側で読者に見せたいため、
# MAGIC 本ノートブックは
# MAGIC
# MAGIC 1. ジョブが実行する各ノートブックの実行ログ設計(`gold.run_logs`)を確認する(コード)
# MAGIC 2. GUI でのジョブ作成・スケジュール設定手順をまとめる(記事原稿にそのまま転記できる粒度で記載)
# MAGIC 3. GUI で作成したジョブを API 経由で取得し、構成・実行結果を確認する(コード)
# MAGIC 4. 運用メモ(更新頻度・クォータ・VACUUM)
# MAGIC
# MAGIC という構成にする。
# COMMAND ----------
# MAGIC %md
# MAGIC ## 4-0. 実行ログ設計(`gold.run_logs`)の確認
# MAGIC
# MAGIC ここでは **専用のログ記録タスクは設けない**。代わりに `01`/`02`/`03` の各ノートブックが
# MAGIC 自身の実行末尾で `stock_analysis.gold.run_logs` に1行ずつ追記する(`mode('append')` + `mergeSchema`)。
# MAGIC 週次ジョブが3タスクを順に流すと、1回の実行につき `collect` / `screening` / `scoring` の3行が積み上がる。
# MAGIC
# MAGIC | 列 | 型 | 意味 |
# MAGIC |---|---|---|
# MAGIC | `run_date` | string | 実行時刻(ISO8601。サーバーレスの時計のため UTC で、オフセットは付かない。JST は +9 時間) |
# MAGIC | `phase` | string | `collect` / `screening` / `scoring` |
# MAGIC | `record_count` | int | そのフェーズの件数(collect:収集成功銘柄数 / screening:通過数 / scoring:付与数) |
# MAGIC | `top_score` | double | 最高スコア(`scoring` のみ。他は NULL) |
# MAGIC | `status` | string | `success` |
# MAGIC
# MAGIC > テーブルは初回ジョブ実行時に各ノートブックが自動生成する。まだ実行前の場合は下のセルで
# MAGIC > 「未作成」と表示される(正常)。
# COMMAND ----------
from pyspark.sql import SparkSession
spark = SparkSession.builder.getOrCreate()
RUN_LOGS_TBL = 'stock_analysis.gold.run_logs'
if spark.catalog.tableExists(RUN_LOGS_TBL):
print(f'✅ {RUN_LOGS_TBL} は作成済み')
print('\n--- 直近の実行ログ(新しい順) ---')
spark.sql(f"""
SELECT run_date, phase, record_count, top_score, status
FROM {RUN_LOGS_TBL}
ORDER BY run_date DESC
LIMIT 15
""").show(truncate=False)
else:
print(f'ℹ️ {RUN_LOGS_TBL} は未作成(週次ジョブ初回実行で自動生成される。実行前なら正常)')
# COMMAND ----------
# MAGIC %md
# MAGIC ## 4-1. Workflow ジョブ作成(GUI 操作手順)
# MAGIC
# MAGIC **目標構成:**
# MAGIC
# MAGIC ```
# MAGIC ジョブ名 : stock_screening_weekly
# MAGIC スケジュール: 毎週日曜 7:00 AM(JST)
# MAGIC コンピュート: Serverless(Free Edition)
# MAGIC
# MAGIC タスク構成(順次依存):
# MAGIC Task 1: collect_data → notebooks/01_collect_data (depends_on: なし)
# MAGIC Task 2: screening → notebooks/02_screening (depends_on: collect_data)
# MAGIC Task 3: scoring → notebooks/03_scoring (depends_on: screening)
# MAGIC ```
# MAGIC
# MAGIC **操作手順:**
# MAGIC
# MAGIC 1. 左ナビゲーションの **Jobs & Pipelines**(Workflows)→ **Create** → **Job** を開く。
# MAGIC 2. ジョブ名に `stock_screening_weekly` を入力。
# MAGIC 3. **Task 1** を作成:
# MAGIC - Task name: `collect_data`
# MAGIC - Type: `Notebook`
# MAGIC - Source: `Workspace`、Path: `.../stock_analysis/notebooks/01_collect_data`
# MAGIC - Compute: `Serverless`
# MAGIC 4. **+ Add task** で **Task 2** を作成:
# MAGIC - Task name: `screening` / Notebook: `.../notebooks/02_screening`
# MAGIC - **Depends on**: `collect_data`
# MAGIC 5. **+ Add task** で **Task 3** を作成:
# MAGIC - Task name: `scoring` / Notebook: `.../notebooks/03_scoring`
# MAGIC - **Depends on**: `screening`
# MAGIC 6. 右パネル **Schedules & Triggers** → **Add trigger** → **Scheduled**:
# MAGIC - Trigger type: `Scheduled`
# MAGIC - Schedule: 毎週日曜 `07:00`、Timezone: `(UTC+09:00) Tokyo`(= JST)
# MAGIC - ※ Advanced(Cron)で指定する場合: `0 0 7 ? * SUN`(Timezone は Asia/Tokyo)
# MAGIC 7. **Create**(保存)後、右上 **Run now** で手動トリガーし、3タスクが順に成功することを確認する。
# MAGIC
# MAGIC > **メモ:** 初回 **Run now** は `01_collect_data` が東証全銘柄×3年分の株価・財務を yfinance から
# MAGIC > 収集するため時間がかかる。Free Edition のコンピュートクォータを消費するので、当日の他実行と競合
# MAGIC > しないタイミングで実行する。
# COMMAND ----------
# MAGIC %md
# MAGIC ## 4-2. ジョブ構成の確認(API 検証)
# MAGIC
# MAGIC GUI で作成したジョブを SDK 経由で取得し、タスク構成・依存関係・スケジュールを検証する。
# MAGIC (ジョブ作成前に実行した場合は「未検出」と表示される。作成後に再実行すること。)
# COMMAND ----------
from databricks.sdk import WorkspaceClient
JOB_NAME = 'stock_screening_weekly'
w = WorkspaceClient()
jobs = [j for j in w.jobs.list(name=JOB_NAME)]
if not jobs:
print(f'ℹ️ ジョブ「{JOB_NAME}」は未検出。GUI(4-1)で作成後に本セルを再実行すること。')
else:
job = w.jobs.get(jobs[0].job_id)
s = job.settings
print(f'ジョブ名 : {s.name}')
print(f'job_id : {job.job_id}')
print(f'タスク数 : {len(s.tasks)}')
print('\n--- タスク構成 ---')
for t in s.tasks:
deps = [d.task_key for d in (t.depends_on or [])]
deps_str = ', '.join(deps) if deps else 'なし'
nb = t.notebook_task.notebook_path if t.notebook_task else '(非Notebook)'
print(f' {t.task_key:<14} depends_on={deps_str:<20} → {nb}')
print('\n--- スケジュール ---')
if s.schedule:
print(f' cron : {s.schedule.quartz_cron_expression}')
print(f' timezone: {s.schedule.timezone_id}')
print(f' status : {s.schedule.pause_status}')
else:
print(' スケジュール未設定')
# COMMAND ----------
# MAGIC %md
# MAGIC ## 4-3. 直近の実行結果の確認(API 検証)
# MAGIC
# MAGIC **Run now** またはスケジュール実行の最新 run を取得し、各タスクが成功したかを確認する。
# COMMAND ----------
if not jobs:
print(f'ℹ️ ジョブ「{JOB_NAME}」未検出のためスキップ(4-1 で作成後に実行)。')
else:
runs = list(w.jobs.list_runs(job_id=jobs[0].job_id, limit=1))
if not runs:
print('ℹ️ まだ実行履歴なし。GUI で「Run now」を実行後に再確認すること。')
else:
run = w.jobs.get_run(runs[0].run_id)
st = run.state
print(f'run_id : {run.run_id}')
print(f'life_cycle : {st.life_cycle_state}')
print(f'result_state : {st.result_state}')
print('\n--- タスク別 結果 ---')
for tr in (run.tasks or []):
rs = tr.state.result_state if tr.state else None
print(f' {tr.task_key:<14} {rs}')
# COMMAND ----------
# MAGIC %md
# MAGIC ## 4-4. 運用メモ
# MAGIC
# MAGIC - **データ更新頻度:** 週1回(日曜夜〜月曜朝)。
# MAGIC - **スクリーニング条件見直し:** 3ヶ月ごとに市場環境に合わせて `02_screening` の閾値パラメータを調整。
# MAGIC - **Free Edition コンピュートクォータ:** 超過する場合は `01_collect_data` のバッチを500銘柄単位に分割。
# MAGIC - **Delta Table バキューム:** 月1回、株価履歴テーブルの不要ファイルを掃除する(下セルは手動運用時のみ実行)。
# MAGIC - **ダッシュボード:** ジョブ成功後、ダッシュボードが最新 `screened_date` を表示するか確認する。
# COMMAND ----------
# 月次メンテナンス例(通常は実行しない。手動運用時のみコメントアウトを外す)
# spark.sql('VACUUM stock_analysis.bronze.stock_prices RETAIN 168 HOURS')
# print('✅ VACUUM 完了')
print('運用メモセル(VACUUM は既定で無効化。必要時のみ手動実行)')
# COMMAND ----------
# MAGIC %md
# MAGIC ## 完了確認
# COMMAND ----------
print('=== 完了確認 ===')
checks = []
# (1) 3ノートブックが run_logs 記録セルを持つ(ソースを確認)
import base64
expected_phases = {'01_collect_data': 'collect', '02_screening': 'screening', '03_scoring': 'scoring'}
base = '/Workspace/Users/<あなたのユーザー>/stock_analysis/notebooks' # ★ 自分の環境の値に置き換える
for nb, phase in expected_phases.items():
export = w.workspace.export(f'{base}/{nb}')
src = base64.b64decode(export.content).decode('utf-8')
ok = 'run_logs' in src and f"'{phase}'" in src
checks.append(ok)
print(f' {nb:<16} run_logs 記録セル: {"✅" if ok else "❌"}')
# (2) ジョブが存在し、3タスク順次依存・週次スケジュールが設定されている
if jobs:
s = w.jobs.get(jobs[0].job_id).settings
task_ok = len(s.tasks) == 3
sched_ok = s.schedule is not None and 'SUN' in (s.schedule.quartz_cron_expression or '').upper()
print(f' ジョブ 3タスク構成 : {"✅" if task_ok else "❌"}')
print(f' 週次スケジュール : {"✅" if sched_ok else "❌"}')
checks += [task_ok, sched_ok]
else:
print(' ⚠️ ジョブ未作成(GUI 4-1 で作成後、本ノートブックを再実行して確認)')
print()
if all(checks) and jobs:
print('✅ 全チェック通過(run_logs 記録 + 週次ジョブ設定)')
else:
print('ℹ️ コード側(run_logs 記録)は準備完了。残りは GUI でのジョブ作成・スケジュール設定・Run now。')
notebooks/05_genie_setup.py
v_candidates ビュー作成 + Genie 簡易エージェント編
# Databricks notebook source
# MAGIC %md
# MAGIC # v_candidates ビュー作成 + Genie 簡易エージェント編
# MAGIC
# MAGIC Gold 層データを、ダッシュボードと Genie の双方が共通で参照できる整形済みビュー
# MAGIC `stock_analysis.gold.v_candidates` として整備し、それに対して Databricks Genie Agent を構築する。
# MAGIC 自然言語でスクリーニング結果に問い合わせられるようにする。
# MAGIC
# MAGIC **設計方針:** `v_candidates` はダッシュボード専用ではなく、Genie/ダッシュボード双方が参照する
# MAGIC 共通のコメント付きビューとして最初にここで作成する。生テーブル(`screening_results`)は内部処理用の列
# MAGIC (`macd`, `bb_upper`, `ticker_x`/`ticker_y`, `passed_fundamental` 等)を多数含み、Genie の誤った
# MAGIC SQL 生成を誘発しやすいため、Genie Agent は最初から `v_candidates` のみを参照する。
# MAGIC **Space 本体は GUI(Databricks Genie 画面)で作成する。** CLI/SDK で自動生成することも可能だが、
# MAGIC 「設定だけでエージェントが立つ手軽さ」をそのまま読者に見せたいため(`book/part2/05_genie_agent.md`)、
# MAGIC 本ノートブックでは
# MAGIC
# MAGIC 1. `v_candidates` ビューを列コメント付きで作成する(コード)
# MAGIC 2. Genie の GUI 操作手順をまとめる(記事原稿にそのまま転記できる粒度で記載)
# MAGIC 3. GUI で作成した Space に対し、自然言語クエリを送って生成 SQL・結果を確認する(コード)
# MAGIC
# MAGIC という3部構成にする。
# COMMAND ----------
# MAGIC %md
# MAGIC ## 5-0. SDK の更新
# MAGIC
# MAGIC サーバーレスの既定環境にある `databricks-sdk` は古く、Genie Agent を取得する `genie.get_space()` を
# MAGIC 持たない。先に SDK を更新し、Python を再起動する(変数は消えるので、ノートブックの先頭で行う)。
# COMMAND ----------
# MAGIC %pip install -qU databricks-sdk
# MAGIC
# MAGIC dbutils.library.restartPython()
# COMMAND ----------
# MAGIC %md
# MAGIC ## 5-1. `v_candidates` ビュー作成(列コメント付き)
# MAGIC
# MAGIC ビューの列コメントは後から `ALTER` で追加・変更できないため、
# MAGIC `CREATE VIEW` の列リストにインラインでコメントを指定する。
# COMMAND ----------
from pyspark.sql import SparkSession
spark = SparkSession.builder.getOrCreate()
VIEW = 'stock_analysis.gold.v_candidates'
spark.sql(f"""
CREATE OR REPLACE VIEW {VIEW} (
rank COMMENT 'scoreの降順ランキング(1位が最高スコア)',
code COMMENT '証券コード(4桁)',
name COMMENT '銘柄名',
market_segment COMMENT '市場区分(プライム/スタンダード/グロース)',
industry_33 COMMENT '業種(33業種分類)',
current_price COMMENT '直近終値(円)',
per COMMENT '株価収益率。低いほど割安とされるが業種により適正水準が異なる',
pbr COMMENT '株価純資産倍率。低いほど割安とされる',
dividend_yield_pct COMMENT '配当利回り(%表記。yfinance側で既に%値のため換算不要)',
roe_pct COMMENT '自己資本利益率(%表記)。高いほど資本効率が良い',
roa_pct COMMENT '総資産利益率(%表記)。高いほど資産効率が良い',
gross_margin_pct COMMENT '粗利益率(%表記)。価格決定力・参入障壁の強さの目安',
fcf_margin_pct COMMENT 'FCF(フリーキャッシュフロー)マージン(%表記)。実際に現金を生む力の目安',
revenue_growth_pct COMMENT '売上成長率(%表記、前年同期比)',
earnings_growth_pct COMMENT '利益成長率(%表記、前年同期比)',
operating_margin_pct COMMENT '営業利益率(%表記)。本業の収益力の目安',
debt_to_equity COMMENT 'D/E比率(%換算値。100=負債と自己資本が同額)。低いほど財務健全性が高い',
current_ratio COMMENT '流動比率。短期支払能力の目安。高いほど安全',
momentum_12m_pct COMMENT '12ヶ月価格モメンタム(%表記、過去1年の株価騰落率)',
score COMMENT 'ルールベーススコア(100点満点)。収益品質・成長性・バリュエーション・財務健全性・長期トレンドの5カテゴリで算出',
ai_comment COMMENT '上位30銘柄に付与された、長期保有目線(ビジネスモデル持続性・競争優位性・リスク要因)のAIコメント',
screened_date COMMENT 'スクリーニング実行日'
)
AS
SELECT
rank,
code,
name,
market_segment,
industry_33,
ROUND(close, 0) AS current_price,
ROUND(per, 1) AS per,
ROUND(pbr, 2) AS pbr,
ROUND(dividend_yield, 2) AS dividend_yield_pct,
ROUND(roe * 100, 1) AS roe_pct,
ROUND(roa * 100, 1) AS roa_pct,
ROUND(gross_margin * 100, 1) AS gross_margin_pct,
ROUND(fcf_margin * 100, 1) AS fcf_margin_pct,
ROUND(revenue_growth * 100, 1) AS revenue_growth_pct,
ROUND(earnings_growth * 100, 1) AS earnings_growth_pct,
ROUND(operating_margin * 100, 1) AS operating_margin_pct,
ROUND(debt_to_equity, 0) AS debt_to_equity,
ROUND(current_ratio, 1) AS current_ratio,
ROUND(price_mom_12m * 100, 1) AS momentum_12m_pct,
ROUND(score, 0) AS score,
COALESCE(ai_comment, '—') AS ai_comment,
DATE(screened_at) AS screened_date
FROM stock_analysis.gold.screening_results
ORDER BY rank
""")
spark.sql(f"""
COMMENT ON TABLE {VIEW} IS
'東証プライム・スタンダード・グロース市場から、ファンダメンタル指標(PER・PBR・ROE・粗利益率・FCFなど)と
テクニカル指標(200日移動平均線・出来高など)でスクリーニングした中長期(3〜5年)保有候補銘柄。
Genie/ダッシュボード向けに整形済み(比率は%表記に変換済み)。
scoreはルールベースで算出した100点満点の総合スコア、rankはscore降順の順位、
ai_commentは上位30銘柄に付与された長期保有目線のAIコメント。'
""")
row_count = spark.table(VIEW).count()
print(f'✅ {VIEW} 作成完了: {row_count} 件')
# COMMAND ----------
# MAGIC %md
# MAGIC ## 5-2. Genie Agent 作成(GUI 操作手順)
# MAGIC
# MAGIC Databricks ワークスペースの左サイドナビゲーションから以下の手順で Space を作成する。
# MAGIC
# MAGIC 1. 左ナビゲーション「**Genie**」(または「+ New」→「**Genie agent**」)を開く
# MAGIC 2. 「**Create**」で Space 新規作成。Space 名の例: `東証スクリーニング候補 Genie`
# MAGIC 3. 「**Add data**」で対象テーブルとして `stock_analysis.gold.v_candidates` を選択
# MAGIC 4. 「**SQL warehouse**」欄でウェアハウスを選択(Free Edition では Serverless SQL ウェアハウスを指定)
# MAGIC 5. 「**Instructions**」タブに以下のセマンティック指示を貼り付ける:
# MAGIC
# MAGIC ```text
# MAGIC このデータ(stock_analysis.gold.v_candidates)は、東証プライム・スタンダード・グロース市場の
# MAGIC 上場銘柄から、ファンダメンタル指標(PER・PBR・ROE・粗利益率・FCFなど)とテクニカル指標
# MAGIC (200日移動平均線・出来高など)でスクリーニングした「中長期(3〜5年)保有候補」銘柄のみを含みます。
# MAGIC ダッシュボード表示・自然言語での問い合わせ向けに整形済みで、比率指標は小数ではなく%表記
# MAGIC (列名末尾が `_pct`)に変換されています。
# MAGIC - score: ルールベースで算出した100点満点の総合スコア(収益品質・成長性・バリュエーション・
# MAGIC 財務健全性・長期トレンドの5カテゴリ)
# MAGIC - rank: score降順の順位
# MAGIC - ai_comment: 上位30銘柄に付与された、長期保有目線のAIコメント
# MAGIC - per, pbr, dividend_yield_pct, roe_pct, roa_pct, gross_margin_pct, fcf_margin_pct,
# MAGIC operating_margin_pct, revenue_growth_pct, earnings_growth_pct, debt_to_equity, current_ratio はすべて
# MAGIC ファンダメンタル指標であり、値が小さい/大きいほど良いかは指標により異なる点に注意すること。
# MAGIC - debt_to_equity: %換算値(100 が負債と自己資本が同額)。「D/E 比率が1以下」は100以下と読み替えること。
# MAGIC - momentum_12m_pct: 過去12ヶ月の株価騰落率(長期の指標)。短期モメンタム扱いせず、回答に使ってよい。
# MAGIC - MACD・RSIなどの短期モメンタム指標は本システムのスコアリングでは重視していないため、
# MAGIC 回答で優先しないこと。
# MAGIC ```
# MAGIC
# MAGIC 6. 「**Sample questions**」に以下のサンプル質問を最低3件登録する:
# MAGIC - 「PERが15倍以下で配当利回りが高い銘柄を教えて」
# MAGIC - 「粗利益率が50%以上の銘柄を教えて」
# MAGIC - 「スコアが70点以上の銘柄をスコア順に見せて」
# MAGIC 7. 右上「**Publish**」で Space を公開する
# MAGIC 8. 公開後のブラウザURL(`.../genie/rooms/<SPACE_ID>`)から `SPACE_ID` をコピーする
# COMMAND ----------
# MAGIC %md
# MAGIC ## 5-3. Space ID の設定
# MAGIC
# MAGIC GUI で作成した Space の ID を下のセルに貼り付ける。
# COMMAND ----------
from databricks.sdk import WorkspaceClient
SPACE_ID = '<あなたのGenie Agent ID>' # ★ 自分の環境の値に置き換える
w = WorkspaceClient()
space = w.genie.get_space(SPACE_ID)
print(f'Space title : {space.title}')
print(f'Description : {space.description}')
print(f'Warehouse ID: {space.warehouse_id}')
# COMMAND ----------
# MAGIC %md
# MAGIC ## 5-4. 自然言語クエリ動作確認
# MAGIC
# MAGIC GUI で作成した Space に対し、代表的な質問パターンを3件送信し、
# MAGIC 妥当な SQL が生成され結果が返ることを確認する。
# COMMAND ----------
TEST_QUESTIONS = [
'PERが15倍以下で配当利回りが高い銘柄を教えて',
'粗利益率が50%以上の銘柄を教えて',
'スコアが70点以上の銘柄をスコア順に見せて',
]
results = []
for question in TEST_QUESTIONS:
print(f'--- 質問: {question} ---')
message = w.genie.start_conversation_and_wait(space_id=SPACE_ID, content=question)
sql_text = None
row_count = None
for att in message.attachments or []:
if att.text:
print(f'回答: {att.text.content}')
if att.query:
sql_text = att.query.query
print(f'生成SQL:\n{sql_text}')
qr = w.genie.get_message_attachment_query_result(
SPACE_ID, message.conversation_id, message.id, att.attachment_id
)
data = qr.statement_response.result.data_array if qr.statement_response.result else []
row_count = len(data) if data else 0
print(f'結果件数: {row_count} 件(先頭3件: {data[:3] if data else []})')
results.append({
'question': question,
'status': str(message.status),
'sql_generated': sql_text is not None,
'sql_references_view': sql_text is not None and 'v_candidates' in sql_text,
'row_count': row_count,
})
print()
# COMMAND ----------
# MAGIC %md
# MAGIC ## 完了確認
# COMMAND ----------
print('=== 完了確認 ===')
assert spark.catalog.tableExists(VIEW), f'{VIEW} が存在しない'
expected_cols = {
'rank', 'code', 'name', 'market_segment', 'industry_33', 'current_price', 'per', 'pbr',
'dividend_yield_pct', 'roe_pct', 'roa_pct', 'gross_margin_pct', 'fcf_margin_pct',
'revenue_growth_pct', 'earnings_growth_pct', 'operating_margin_pct', 'debt_to_equity',
'current_ratio', 'momentum_12m_pct', 'score', 'ai_comment', 'screened_date',
}
actual_cols = set(spark.table(VIEW).columns)
assert expected_cols <= actual_cols, f'不足カラム: {expected_cols - actual_cols}'
print(f'✅ {VIEW}: {len(actual_cols)} 列、{spark.table(VIEW).count()} 件')
assert SPACE_ID, 'SPACE_ID が未設定(GUIで作成したSpaceのIDを貼り付けること)'
assert space.warehouse_id, 'Spaceにウェアハウスが紐づいていない'
for r in results:
print(f"[{r['status']}] SQL生成={r['sql_generated']} v_candidates参照={r['sql_references_view']} "
f"結果件数={r['row_count']} 質問={r['question']}")
assert r['sql_generated'], f"質問「{r['question']}」でSQLが生成されなかった"
assert r['sql_references_view'], f"質問「{r['question']}」の生成SQLがv_candidatesを参照していない"
print('\n✅ v_candidates ビューが作成され、Genie Agentがそれを参照して動作していることを確認')
print('✅ 自然言語クエリ 3パターンすべてでSQLが生成され、v_candidatesを参照していることを確認(status・結果件数は上の出力で目視確認)')
print('\n--- 手動確認チェック(GUI側、コードでは検証不可) ---')
print('[ ] 「Genie Agent を作る」の手順どおりInstructionsを設定した')
print('[ ] 「Genie Agent を作る」の手順どおりSample questionsを3件以上登録した')
print('\n次のステップ → LLMOps独自エージェント編 / ダッシュボード(v_candidatesを再利用)')
notebooks/06a_llmops_agent.py
LLMOps独自エージェント編
# Databricks notebook source
# MAGIC %md
# MAGIC # LLMOps独自エージェント編
# MAGIC
# MAGIC Genie(自然言語→自由SQL)に対し、`gold.v_candidates` を参照する
# MAGIC **限定ツール**だけを持つ LangGraph エージェントを実装し、MLflow で
# MAGIC トレース・評価しながら品質を継続検証できる状態にする(=Genie では作り込めない "作り込み")。
# MAGIC
# MAGIC | セル | 内容 |
# MAGIC | --- | --- |
# MAGIC | 1 | ライブラリインストール |
# MAGIC | 2 | 環境変数(本体LLM / ジャッジ / 参照テーブル) |
# MAGIC | 3 | `agents/` を sys.path に追加 |
# MAGIC | 4 | MLflow experiment |
# MAGIC | 5 | 疎通確認(本体LLM・ジャッジ・evaluate) |
# MAGIC | 6 | Vibe Check(トレース生成) |
# MAGIC | 7 | スコアラー定義 |
# MAGIC | 8 | スコアラー個別テスト |
# MAGIC | 9 | スコアラー登録(冪等化) |
# MAGIC | 10 | 評価データセット |
# MAGIC | 11 | `mlflow.genai.evaluate()` の実行 |
# MAGIC
# MAGIC **前提**: スクリーニング・スコアリング完了済み(`stock_analysis.gold.v_candidates` が存在すること)。
# COMMAND ----------
# DBTITLE 1,セル1: ライブラリのインストール
# MAGIC %pip install -qU \
# MAGIC "mlflow[databricks]>=3.9.0,<3.12" \
# MAGIC "litellm>=1.0.0" \
# MAGIC "langgraph>=1.2.0" \
# MAGIC "langchain>=1.2.0" \
# MAGIC "langchain-core>=1.2.0" \
# MAGIC "databricks-langchain>=0.20"
# MAGIC
# MAGIC dbutils.library.restartPython()
# COMMAND ----------
# DBTITLE 1,セル2: 環境変数
import os
# 本体LLM: ツール呼び出しの信頼性のため 70b。Serving タブで実名を確認すること。
# Free Edition 実測(2026-07-21): 3-1-70b は存在せず、3-3-70b が実在しツール呼び出し可。
os.environ["LLM_ENDPOINT"] = "databricks-meta-llama-3-3-70b-instruct"
# 参照する整形済みビュー(Genie と同じビューを共有)。
os.environ["CANDIDATES_TABLE"] = "stock_analysis.gold.v_candidates"
# ジャッジ用 LLM。本体とは別モデルにして自己評価バイアスを避ける。
# Free Edition では claude / gemini / gpt 系は READY 表示でも rate limit 0 で呼べないため、
# 実際に使える llama 系を指定する。databricks:/ 形式は litellm 経由で呼ばれる。
os.environ["JUDGE_MODEL"] = "databricks:/databricks-llama-4-maverick"
JUDGE_MODEL = os.environ["JUDGE_MODEL"]
print("環境変数を設定しました")
# COMMAND ----------
# DBTITLE 1,セル3: agents/ を import パスに追加
import sys, os
# notebooks/ と agents/ は兄弟フォルダ(stock_analysis/ 直下)。
# ワークスペースでは stock_analysis/notebooks/ と stock_analysis/agents/ に配置される。
agents_dir = os.path.abspath("../agents")
if agents_dir not in sys.path:
sys.path.insert(0, agents_dir)
print("agents dir:", agents_dir)
# COMMAND ----------
# DBTITLE 1,セル4: MLflow experiment
import mlflow
# 本エージェント専用の experiment に記録する(ワークスペース絶対パスで指定)。
EXPERIMENT = "/Users/<あなたのユーザー>/stock_analysis/llmops_agent" # ★ 自分の環境の値に置き換える
experiment = mlflow.set_experiment(EXPERIMENT)
EXPERIMENT_ID = experiment.experiment_id
print("experiment:", EXPERIMENT, f"(id={EXPERIMENT_ID})")
# COMMAND ----------
# MAGIC %md
# MAGIC ## 疎通確認(Free Edition の壁を先に潰す)
# MAGIC
# MAGIC 本体LLM・ジャッジ・`mlflow.genai.evaluate()` が Free Edition で使えるかを最初に確認する。
# MAGIC ジャッジが呼べない場合は `JUDGE_MODEL` を qwen3/gemma 系に差し替える。
# MAGIC `evaluate()` 自体が制限される場合はルールベース簡易スコアラー中心に縮退する。
# COMMAND ----------
# DBTITLE 1,セル5: 疎通確認
import mlflow.deployments
deploy_client = mlflow.deployments.get_deploy_client("databricks")
def _ping(endpoint: str) -> None:
ep = endpoint.replace("databricks:/", "")
try:
resp = deploy_client.predict(
endpoint=ep,
inputs={"messages": [{"role": "user", "content": "hi"}], "max_tokens": 5},
)
print(f" OK {ep}: {resp['choices'][0]['message']['content']!r}")
except Exception as e:
print(f" NG {ep}: {type(e).__name__}: {str(e)[:160]}")
print("--- 本体LLM ---")
_ping(os.environ["LLM_ENDPOINT"])
print("--- ジャッジLLM ---")
_ping(JUDGE_MODEL)
# COMMAND ----------
# MAGIC %md
# MAGIC ## Vibe Check - エージェントを動かしてトレースを生成
# MAGIC
# MAGIC 実際に質問してみて振る舞いを観察する。ここで生成したトレースを後続の
# MAGIC スコアラー個別テストで使う。左ナビゲーションの Experiments > Traces で確認できる。
# COMMAND ----------
# DBTITLE 1,セル6: Vibe Check
from screening_agent import ScreeningAgent, Thread
TEST_QUESTIONS = [
"スコア70点以上で配当利回りが高い銘柄を教えてください。",
"証券コード7203の長期保有の観点での評価を教えてください。",
"情報・通信業でスコアの高い候補はありますか?",
]
agent = ScreeningAgent()
for i, question in enumerate(TEST_QUESTIONS, 1):
print(f"--- 質問 {i}/{len(TEST_QUESTIONS)} ---")
print(f"Q: {question}")
try:
answer = agent.process_query(query=question, thread=Thread())
print("A:", (answer[:400] + "...") if len(answer) > 400 else answer)
except Exception as e:
print(f"エラー: {type(e).__name__}: {e}")
print()
print("Vibe Check 完了! Experiments > Traces でトレースを確認してください。")
# COMMAND ----------
# MAGIC %md
# MAGIC ## スコアラーの定義
# MAGIC
# MAGIC 本テーマ向けに4種類を用意する。
# MAGIC - `Correctness`(LLMジャッジ): 期待回答の要点をカバーしているか
# MAGIC - `ToolCallCorrectness`(LLMジャッジ): 期待ツールを呼んだか(`should_exact_match=True` なら名前と引数の直接比較)
# MAGIC - `mentions_risk`(`@scorer` ルールベース): リスク要因に言及しているか
# MAGIC - `has_disclaimer`(`Guidelines`): 投資助言でなく参考情報である旨の免責があるか
# COMMAND ----------
# DBTITLE 1,セル7: スコアラー定義
import re
from mlflow.genai.scorers import scorer, Correctness, Guidelines, ToolCallCorrectness
# 標準スコアラー
correctness = Correctness(model=JUDGE_MODEL)
# ToolCallCorrectness は期待ツールと実際の呼び出しを LLM が意味的に比べる(model 未指定なら既定のジャッジ)
tool_call_correctness = ToolCallCorrectness()
@scorer
def mentions_risk(outputs: str) -> bool:
"""回答がリスク要因に言及しているかを検出する(ルールベース)。
システムプロンプトが「主なリスク要因に必ず触れる」と指示しているため、
その順守をキーワードで簡易チェックする。
"""
text = outputs if isinstance(outputs, str) else str(outputs)
keywords = ["リスク", "懸念", "注意", "債務", "競合", "サイクル", "変動", "鈍化", "下落"]
return any(k in text for k in keywords)
has_disclaimer = Guidelines(
name="has_disclaimer",
guidelines=[
"回答の中に、投資判断の参考情報であり投資助言・推奨ではない旨の免責が含まれている。",
],
model=JUDGE_MODEL,
)
print("スコアラー定義: correctness, tool_call_correctness, mentions_risk, has_disclaimer")
# COMMAND ----------
# MAGIC %md
# MAGIC ## スコアラーの個別テスト
# MAGIC
# MAGIC 直前の Vibe Check で生成した最新トレースを使って各スコアラーを試す。
# COMMAND ----------
# DBTITLE 1,セル8: スコアラー個別テスト
import mlflow
def get_latest_traces(max_results: int = 5):
return mlflow.search_traces(
locations=[EXPERIMENT_ID],
max_results=max_results,
order_by=["timestamp DESC"],
return_type="list",
)
def print_result(result, name: str = ""):
if not hasattr(result, "name"):
print(f" name: {name}\n value: {result}\n")
return
print(f" name: {result.name}\n value: {result.value}")
if getattr(result, "rationale", None):
rationale = result.rationale
print(f" rationale: {rationale[:200] + '...' if len(rationale) > 200 else rationale}")
print()
traces = get_latest_traces(max_results=3)
assert traces, "トレースがありません。先に Vibe Check のセルを実行してください。"
trace = traces[0]
print(f"トレースID: {trace.info.trace_id}\n")
# --- ToolCallCorrectness(ルールベース)---
print("--- ToolCallCorrectness ---")
print_result(
tool_call_correctness(
trace=trace,
expectations={"expected_tool_calls": [{"name": "search_candidates"}]},
)
)
# --- mentions_risk(@scorer ルールベース)---
print("--- mentions_risk ---")
outputs = trace.data.spans[0].outputs
output_text = outputs if isinstance(outputs, str) else str(outputs)
print_result(mentions_risk(outputs=output_text), name="mentions_risk")
# --- has_disclaimer(Guidelines)---
print("--- has_disclaimer ---")
print_result(has_disclaimer(trace=trace))
# --- Correctness(LLMジャッジ)---
print("--- Correctness ---")
expected = (
"情報・通信業の候補からスコアの高い銘柄を挙げ、"
"長期保有目線の要点とリスク、免責を添えて回答する。"
)
print_result(correctness(trace=trace, expectations={"expected_response": expected}))
# COMMAND ----------
# MAGIC %md
# MAGIC ## スコアラーの登録
# MAGIC
# MAGIC Guidelines スコアラーを experiment に登録してバージョン管理する。
# MAGIC Databricks では `register()` が冪等でないため、登録済みかを先に確認する。
# COMMAND ----------
# DBTITLE 1,セル9: スコアラー登録(冪等化)
from mlflow.genai import get_scorer
from mlflow.genai.scorers import list_scorers
registered = {s.name for s in list_scorers(experiment_id=EXPERIMENT_ID)}
for s in (has_disclaimer,):
if s.name in registered:
print(f" {s.name}: 登録済みのためスキップ")
else:
s.register(experiment_id=EXPERIMENT_ID)
print(f" {s.name}: 登録完了")
# 取得テスト(Databricks はバージョン指定取得に非対応のため version は指定しない)
loaded = get_scorer(name="has_disclaimer", experiment_id=EXPERIMENT_ID)
print(" 取得成功:", loaded.name)
# COMMAND ----------
# MAGIC %md
# MAGIC ## 自動評価の実行
# MAGIC
# MAGIC 評価データセットを定義し、`mlflow.genai.evaluate()` で一括評価する。
# MAGIC LLM ジャッジ呼び出しが発生するため数分かかる。
# COMMAND ----------
# DBTITLE 1,セル10: 評価データセット
eval_dataset = [
{
"inputs": {"question": "スコア70点以上で配当利回りが高い銘柄を教えてください。"},
"expectations": {
"expected_response": (
"スコア70点以上の候補から配当利回りの高い銘柄を挙げ、"
"長期保有目線の要点とリスク要因、免責を添えて回答する。"
),
"expected_tool_calls": [{"name": "search_candidates"}],
},
},
{
"inputs": {"question": "証券コード7203の長期保有の観点での評価を教えてください。"},
"expectations": {
"expected_response": (
"指定銘柄の指標(収益品質・成長性・バリュエーション・財務健全性)に基づき、"
"長期保有目線の評価とリスク、免責を添えて回答する。"
"通過銘柄でなければその旨を伝える。"
),
"expected_tool_calls": [{"name": "get_candidate_detail"}],
},
},
{
"inputs": {"question": "PERが15倍以下で財務が健全な候補はありますか?"},
"expectations": {
"expected_response": (
"PER15倍以下の候補を挙げ、財務健全性(D/E・流動比率)に触れ、"
"リスクと免責を添えて回答する。"
),
"expected_tool_calls": [{"name": "search_candidates"}],
},
},
{
"inputs": {"question": "医薬品業種の候補をスコア順に見せてください。"},
"expectations": {
"expected_response": (
"医薬品業種の候補をスコア順に挙げ、各銘柄の要点とリスク、免責を添えて回答する。"
"該当がなければその旨を伝える。"
),
"expected_tool_calls": [{"name": "search_candidates"}],
},
},
{
"inputs": {"question": "証券コード9999の評価を教えてください。"},
"expectations": {
"expected_response": (
"証券コード9999はスクリーニング通過銘柄に含まれないため、"
"その旨を正直に伝える。"
),
"expected_tool_calls": [{"name": "get_candidate_detail"}],
},
},
]
print(f"評価データセット: {len(eval_dataset)}件")
# COMMAND ----------
# DBTITLE 1,セル11: evaluate() の実行
import mlflow
def predict_fn(question: str) -> str:
"""評価用の predict 関数。データセットの inputs["question"] に対応する。"""
return agent.process_query(query=question, thread=Thread())
print("評価を開始します...\n")
eval_results = mlflow.genai.evaluate(
data=eval_dataset,
predict_fn=predict_fn,
scorers=[correctness, tool_call_correctness, mentions_risk, has_disclaimer],
)
print("\n評価完了!")
print("run_id:", eval_results.run_id)
display(eval_results.metrics)
notebooks/06b_prompt_registry.py
Prompt Registry — システムプロンプトを UC で管理
# Databricks notebook source
# MAGIC %md
# MAGIC # Prompt Registry — システムプロンプトを UC で管理
# MAGIC
# MAGIC エージェント本体(`agents/screening_agent.py`)にハードコードしていたシステムプロンプトを、
# MAGIC Unity Catalog の Prompt Registry に登録してバージョン管理する。`@production` エイリアスで
# MAGIC 「どのバージョンを本番にするか」を管理し、次の `06c_deploy_agent.py` がデプロイ時にこれを解決する。
# MAGIC
# MAGIC ## Databricks 特有の注意(実測)
# MAGIC - プロンプト名は UC の3レベル `catalog.schema.name` が必須(2レベルは拒否される)
# MAGIC - 予約エイリアス `@latest` は Databricks 非対応 → バージョン検索で代替
# MAGIC - `register_prompt()` は呼ぶたび新バージョンが積まれる → 全バージョン走査で冪等化
# MAGIC - プロンプトは Catalog Explorer には表示されない(テーブル/関数ではないUCオブジェクト。正常)
# COMMAND ----------
# DBTITLE 1,セル1: ライブラリのインストール
# MAGIC %pip install -qU \
# MAGIC "mlflow[databricks]>=3.9.0,<3.12" \
# MAGIC "langgraph>=1.2.0" \
# MAGIC "langchain>=1.2.0" \
# MAGIC "langchain-core>=1.2.0" \
# MAGIC "databricks-langchain>=0.20"
# MAGIC
# MAGIC dbutils.library.restartPython()
# COMMAND ----------
# DBTITLE 1,セル2: 環境変数と作業ディレクトリ
import os, sys
os.environ["LLM_ENDPOINT"] = "databricks-meta-llama-3-3-70b-instruct"
# agents/ を import パスに追加(SYSTEM_PROMPT を読むため)
agents_dir = os.path.abspath("../agents")
if agents_dir not in sys.path:
sys.path.insert(0, agents_dir)
print("agents dir:", agents_dir)
# COMMAND ----------
# DBTITLE 1,セル3: MLflow とスキーマの準備
import mlflow
# プロンプトは Unity Catalog に格納されるため、レジストリを UC に向ける
mlflow.set_registry_uri("databricks-uc")
CATALOG = "stock_analysis"
SCHEMA = "prompts" # プロンプト格納用スキーマ(ライフサイクルが gold と違うため新設)
PROMPT_NAME = f"{CATALOG}.{SCHEMA}.screening-agent-system-prompt"
# プロンプトの格納先スキーマ。CREATE FUNCTION / EXECUTE / MANAGE 権限が要る。
spark.sql(f"CREATE SCHEMA IF NOT EXISTS {CATALOG}.{SCHEMA}")
print("プロンプト名:", PROMPT_NAME)
# COMMAND ----------
# DBTITLE 1,セル4: ヘルパー(冪等な登録・@latest の代替)
from mlflow import MlflowClient
_client = MlflowClient()
def find_version_with_template(name: str, template: str):
"""同じ template を持つ既存バージョンを探す。無ければ None。"""
try:
versions = _client.search_prompt_versions(name)
except Exception:
return None # プロンプト自体が未登録
for v in versions:
loaded = mlflow.genai.load_prompt(f"prompts:/{name}/{v.version}")
if loaded.template.strip() == template.strip():
return loaded
return None
def register_if_new(name: str, template: str, **kwargs):
"""同じ template のバージョンが既にあれば再登録しない(冪等化)。
Databricks でもプロンプトのバージョンは不変で、register_prompt() を呼ぶたびに
新バージョンが積まれる。ノートブックは繰り返し実行するため、全バージョンを
走査して同一内容があればスキップする。
"""
existing = find_version_with_template(name, template)
if existing is not None:
print(f" → v{existing.version} と同一内容のためスキップ(再登録しない)")
return existing
return mlflow.genai.register_prompt(name=name, template=template, **kwargs)
# COMMAND ----------
# DBTITLE 1,セル5: システムプロンプトを登録
# エージェント本体にハードコードされているシステムプロンプトをそのまま登録する
from screening_agent import SYSTEM_PROMPT
prompt = register_if_new(
name=PROMPT_NAME,
template=SYSTEM_PROMPT,
commit_message="スクリーニングエージェントのシステムプロンプト(長期保有目線・リスク言及・免責必須)",
tags={"author": "<あなたのユーザー>", "task": "screening-qa", "language": "ja"}, # ★ 自分の環境の値に置き換える
)
print(f"プロンプト '{prompt.name}' (version {prompt.version})")
print("--- 登録内容(先頭200文字)---")
print(SYSTEM_PROMPT[:200])
# COMMAND ----------
# DBTITLE 1,セル6: @production エイリアスを付与
# デプロイノートブック(06c)が読むのはこの @production。
mlflow.genai.set_prompt_alias(
name=PROMPT_NAME,
alias="production",
version=prompt.version,
)
# 確認: @production でロードできること(@latest は Databricks 非対応なので使わない)
prod = mlflow.genai.load_prompt(f"prompts:/{PROMPT_NAME}@production")
print(f"@production → バージョン {prod.version}")
print("エイリアスを別バージョンに向け直せば、06c を再実行(再デプロイ)するだけで本番プロンプトを切り替えられます。")
# COMMAND ----------
# DBTITLE 1,セル7: 登録済みプロンプトの確認
# Catalog Explorer には出ないため、検索 API で確認する
found = mlflow.genai.search_prompts(
filter_string=f"catalog = '{CATALOG}' AND schema = '{SCHEMA}'"
)
for p in found:
print("-", p.name)
notebooks/06c_deploy_agent.py
サービング — チャット形式で呼び出せるように公開
# Databricks notebook source
# MAGIC %md
# MAGIC # サービング — チャット形式で呼び出せるように公開
# MAGIC
# MAGIC `ScreeningAgent` を `ChatAgent`(`agents/chat_agent.py`)でラップし、
# MAGIC Model Serving エンドポイントとしてデプロイする。デプロイ後は Playground や
# MAGIC OpenAI 互換 API でチャットとして呼び出せる。
# MAGIC
# MAGIC ## 方針(案A + 案① + 専用サービスプリンシパル)
# MAGIC - **データ取得(案A)**: サービングコンテナには Spark が無いため、ツールは SQL ウェアハウス経由で
# MAGIC 候補ビューを読む。セル3でその経路を先に検証してからデプロイする。
# MAGIC - **プロンプト(案①)**: `@production` をこのノートブックで解決し `model_config` で焼き込む。
# MAGIC サービング時に UC プロンプトへは触れない。
# MAGIC - **認証**: 候補データの取得は **専用サービスプリンシパル `stock-analysis-agent-sp`** の
# MAGIC 資格情報で行う(`environment_vars` でシークレット参照を注入)。
# MAGIC
# MAGIC ## なぜ自動認証パススルーだけでは足りないか
# MAGIC
# MAGIC 当初は `log_model(resources=[...])` の自動認証パススルーで足りると考えていた。しかし
# MAGIC **パススルーが渡すのはリソース単位の権限**(ウェアハウスの `Use Endpoint`、テーブルの `SELECT`)で、
# MAGIC `databricks-sql-access` のような**ワークスペースのエンタイトルメントは渡らない**。
# MAGIC デプロイ時に Databricks が自動生成するサービスプリンシパルがその資格を持たないと、リソースを
# MAGIC いくら宣言しても Statement Execution API が `PermissionDenied` で落ちる
# MAGIC (2026-08-15 に実際に発生し、全リクエストが失敗した)。自動生成 SP は Databricks が所有・
# MAGIC ローテーションする対象なので、こちらからエンタイトルメントを管理できない。
# MAGIC
# MAGIC そこで**自分で所有する SP** を立て、必要な資格を明示的に付与する。権限の所在が1つの
# MAGIC アカウントに集約され、足りないものを自分で足せる。
# MAGIC
# MAGIC ## 事前準備(一度だけ・このノートブックの外で実施)
# MAGIC
# MAGIC 1. SP `stock-analysis-agent-sp` を作成し、エンタイトルメント `workspace-access` /
# MAGIC `databricks-sql-access` を付与
# MAGIC 2. SQL ウェアハウスに `CAN_USE` を付与
# MAGIC 3. UC に `USE CATALOG`(`stock_analysis`)/ `USE SCHEMA`(`gold`)/ `SELECT`(`v_candidates`)を付与
# MAGIC 4. OAuth シークレットを発行し、シークレットスコープ `stock_analysis_agent` の
# MAGIC `client_id` / `client_secret` に格納
# MAGIC
# MAGIC **前提**: `06b_prompt_registry.py` を実行済みで `@production` が付いていること。
# COMMAND ----------
# DBTITLE 1,セル1: ライブラリのインストール
# MAGIC %pip install -qU \
# MAGIC "mlflow[databricks]>=3.9.0,<3.12" \
# MAGIC "databricks-agents>=1.1" \
# MAGIC "databricks-langchain>=0.20" \
# MAGIC "langgraph>=1.2.0" \
# MAGIC "langchain>=1.2.0" \
# MAGIC "langchain-core>=1.2.0"
# MAGIC
# MAGIC dbutils.library.restartPython()
# COMMAND ----------
# DBTITLE 1,セル2: 環境変数と作業ディレクトリ
import os, sys
os.environ["LLM_ENDPOINT"] = "databricks-meta-llama-3-3-70b-instruct"
os.environ["CANDIDATES_TABLE"] = "stock_analysis.gold.v_candidates"
os.environ["WAREHOUSE_ID"] = "<あなたのウェアハウスID>" # ★ 自分の環境の値に置き換える
agents_dir = os.path.abspath("../agents")
if agents_dir not in sys.path:
sys.path.insert(0, agents_dir)
print("agents dir:", agents_dir)
# COMMAND ----------
# MAGIC %md
# MAGIC ## セル3: サービング時のデータ取得経路(SQL ウェアハウス)を先に検証
# MAGIC
# MAGIC デプロイ後のコンテナには Spark が無く、ツールは `_load_via_sql_warehouse()` を通る。
# MAGIC ここで明示的にその経路を叩き、候補が取得できること・数値列が数値型になることを確認する。
# MAGIC (ここが壊れているとデプロイしても全リクエストが落ちるため、デプロイ前に潰す。)
# MAGIC
# MAGIC ただし**このセルはあなたの資格情報で実行される**点に注意。サービング時の実行主体は
# MAGIC 専用 SP `stock-analysis-agent-sp` なので、ここが通ってもデプロイ後に落ちることはあり得る
# MAGIC (実際 2026-08-15 の障害はその食い違いだった)。SP 側の権限はセル8のデプロイ後に
# MAGIC エンドポイントを実際に叩いて確認すること。
# COMMAND ----------
# DBTITLE 1,セル3: SQL 経路のスモークテスト
import screening_agent
df = screening_agent._load_via_sql_warehouse()
assert len(df) > 0, "候補が0件です。v_candidates を確認してください。"
assert df["score"].dtype.kind in "fi", f"score が数値型になっていません: {df['score'].dtype}"
print(f"SQL 経路OK: {len(df)} 件取得、最高スコア {df['score'].max():.0f}")
print("列:", list(df.columns))
# COMMAND ----------
# DBTITLE 1,セル4: @production プロンプトを解決(案①: デプロイ時読み込み)
import mlflow
mlflow.set_registry_uri("databricks-uc")
PROMPT_NAME = "stock_analysis.prompts.screening-agent-system-prompt"
prod_prompt = mlflow.genai.load_prompt(f"prompts:/{PROMPT_NAME}@production")
SYSTEM_PROMPT_TEXT = prod_prompt.template
print(f"@production → バージョン {prod_prompt.version}")
print("先頭120文字:", SYSTEM_PROMPT_TEXT[:120])
# COMMAND ----------
# DBTITLE 1,セル5: デプロイ前のローカル動作確認(ChatAgent)
# ノートブックには SP の資格情報(DATABRICKS_CLIENT_ID/SECRET)が無いため、_data_request_scope()
# は何もしないコンテキストを返し、screening_agent は Spark の table 経路にフォールバックする。
# ここでは ChatAgent ラッパーが応答を返すことを確認する。
from chat_agent import ScreeningChatAgent
from mlflow.types.agent import ChatAgentMessage
_agent = ScreeningChatAgent()
resp = _agent.predict(
[ChatAgentMessage(role="user", content="スコア70点以上の候補を1件教えてください。", id="1")]
)
print(resp.messages[0].content[:400])
# COMMAND ----------
# DBTITLE 1,セル6: モデルのログ(Models from Code)
import mlflow
from mlflow.models.resources import DatabricksServingEndpoint, DatabricksSQLWarehouse
mlflow.set_registry_uri("databricks-uc")
# デプロイ後のリアルタイムトレースを Playground で見られるよう、ログ前に experiment を指定する
mlflow.set_experiment("/Users/<あなたのユーザー>/stock_analysis/llmops_agent") # ★ 自分の環境の値に置き換える
# エージェント資産の登録先スキーマ(gold/prompts とはライフサイクルが違うため新設)
spark.sql("CREATE SCHEMA IF NOT EXISTS stock_analysis.agents")
UC_MODEL = "stock_analysis.agents.screening_agent"
# ── 自動認証パススルーで足りるもの ───────────────────────────────────────
# LLM 推論は自動生成 SP の資格で問題なく通っている(エンタイトルメント不要のため)。
# ウェアハウスとテーブルも宣言はしておく。実際のデータ取得は専用 SP が行うが、
# 依存関係をモデルのメタデータとして残す意味がある(リネージ・可搬性)。
resources = [
DatabricksServingEndpoint(endpoint_name="databricks-meta-llama-3-3-70b-instruct"),
DatabricksSQLWarehouse(warehouse_id="<あなたのウェアハウスID>"), # ★ 自分の環境の値に置き換える
]
try:
from mlflow.models.resources import DatabricksTable
resources.append(DatabricksTable(table_name="stock_analysis.gold.v_candidates"))
except Exception as e:
print("DatabricksTable リソースは未対応のためスキップ:", e)
with mlflow.start_run():
logged = mlflow.pyfunc.log_model(
name="agent",
python_model=f"{agents_dir}/chat_agent.py", # Models from Code
code_paths=[f"{agents_dir}/screening_agent.py"], # 依存モジュールを同梱
model_config={ # 案①: プロンプトを焼き込む
"system_prompt": SYSTEM_PROMPT_TEXT,
"llm_endpoint": "databricks-meta-llama-3-3-70b-instruct",
"candidates_table": "stock_analysis.gold.v_candidates",
"warehouse_id": "<あなたのウェアハウスID>", # ★ 自分の環境の値に置き換える
},
pip_requirements=[
"mlflow[databricks]>=3.9.0,<3.12",
"databricks-langchain>=0.20",
"databricks-sdk>=0.30",
"langgraph>=1.2.0",
"langchain>=1.2.0",
"langchain-core>=1.2.0",
"pandas",
],
resources=resources,
registered_model_name=UC_MODEL,
input_example={"messages": [{"role": "user", "content": "スコアの高い候補は?"}]},
)
print("model_uri:", logged.model_uri)
print("version:", logged.registered_model_version)
# COMMAND ----------
# DBTITLE 1,セル7: デプロイ
from databricks import agents
# データ取得用の専用 SP 資格情報をシークレット参照で注入する。値そのものはノートブックにも
# モデルにも残らず、サービング実行時にコンテナの環境変数へ展開される。
deployment = agents.deploy(
UC_MODEL,
logged.registered_model_version,
scale_to_zero=True, # 無負荷時ゼロスケール(Free Edition のリソース節約)
environment_vars={
"DATABRICKS_CLIENT_ID": "{{secrets/stock_analysis_agent/client_id}}",
"DATABRICKS_CLIENT_SECRET": "{{secrets/stock_analysis_agent/client_secret}}",
},
)
print(deployment)
# COMMAND ----------
# DBTITLE 1,セル8: デプロイ状況の確認
from databricks.agents import get_deployments
for d in get_deployments(UC_MODEL):
print("エンドポイント:", d.endpoint_name)
print("Review App URL:", getattr(d, "review_app_url", "(Serving タブから開けます)"))
print("\nエンドポイントの READY 化には数分〜十数分かかります。")
print("Serving タブ、または `databricks serving-endpoints query <name>` で確認してください。")
notebooks/07_dashboard.py
ダッシュボード・可視化
# Databricks notebook source
# MAGIC %md
# MAGIC # ダッシュボード・可視化
# MAGIC
# MAGIC 作成済みの `stock_analysis.gold.v_candidates` を使い、Databricks AI/BI Dashboard で可視化する。
# MAGIC **Genie と同様、ダッシュボード本体は GUI(Databricks Dashboards ビルダー)で作成する。**
# MAGIC 「設定だけでダッシュボードが立つ手軽さ」をそのまま読者に見せたいため、本ノートブックでは
# MAGIC
# MAGIC 1. 各ウィジェット用の SQL クエリを実行・検証する(コード)
# MAGIC 2. GUI 操作手順をまとめる(記事原稿にそのまま転記できる粒度で記載)
# MAGIC 3. GUI で作成したダッシュボードを API 経由で取得し、構成を確認する(コード)
# MAGIC
# MAGIC という3部構成にする。
# COMMAND ----------
# MAGIC %md
# MAGIC ## 7-1. ウィジェット用 SQL クエリの検証
# MAGIC
# MAGIC GUI でデータセットとして貼り付けるクエリ(+ KPI 用集計)を先に実行し、
# MAGIC エラーがないこと・意味のある結果が返ることを確認する。
# COMMAND ----------
from pyspark.sql import SparkSession
spark = SparkSession.builder.getOrCreate()
QUERIES = {
'kpi_summary': """
SELECT COUNT(*) AS total_candidates, ROUND(AVG(score), 1) AS avg_score, MAX(score) AS top_score
FROM stock_analysis.gold.v_candidates
""",
'candidates_ranking': """
SELECT rank, code, name, market_segment, industry_33,
current_price, per, pbr, roe_pct, gross_margin_pct, fcf_margin_pct, score, ai_comment
FROM stock_analysis.gold.v_candidates
WHERE score >= 60
ORDER BY rank
LIMIT 50
""",
'industry_summary': """
SELECT industry_33, COUNT(*) AS candidate_count, ROUND(AVG(score), 1) AS avg_score
FROM stock_analysis.gold.v_candidates
GROUP BY industry_33
ORDER BY candidate_count DESC
""",
'score_distribution': """
SELECT score FROM stock_analysis.gold.v_candidates
""",
'quality_map': """
SELECT name, gross_margin_pct, fcf_margin_pct, score, current_price
FROM stock_analysis.gold.v_candidates
WHERE gross_margin_pct IS NOT NULL AND fcf_margin_pct IS NOT NULL
""",
'last_updated': """
SELECT MAX(screened_date) AS last_updated FROM stock_analysis.gold.v_candidates
""",
}
for name, sql in QUERIES.items():
df = spark.sql(sql).toPandas()
print(f'--- {name}: {len(df)} 行 ---')
print(df.head(3).to_string(index=False))
print()
# COMMAND ----------
# MAGIC %md
# MAGIC ## 7-2. AI/BI Dashboard 作成(GUI 操作手順)
# MAGIC
# MAGIC 1. 左ナビゲーション「**Dashboards**」→「**Create dashboard**」
# MAGIC 2. ダッシュボード名を設定。例: `東証スクリーニング候補ダッシュボード`
# MAGIC 3. 「**Data**」タブで新規データセットを6件追加し、7-1 の SQL をそれぞれ貼り付ける
# MAGIC (データセット名は下表の名前に合わせる):
# MAGIC
# MAGIC | データセット名 | 用途 |
# MAGIC |---|---|
# MAGIC | `kpi_summary` | KPIカウンター(候補銘柄数・平均スコア・最高スコア) |
# MAGIC | `candidates_ranking` | 候補銘柄ランキングテーブル |
# MAGIC | `industry_summary` | 業種別候補数(棒グラフ) |
# MAGIC | `score_distribution` | スコア分布(ヒストグラム) |
# MAGIC | `quality_map` | 収益品質マップ(散布図) |
# MAGIC | `last_updated` | 直近更新日時 |
# MAGIC
# MAGIC 4. SQL Warehouse を選択(Free Edition では Serverless SQL ウェアハウスを指定)
# MAGIC 5. 「**Canvas**」タブに戻り、以下のウィジェットを配置する:
# MAGIC - **KPIカウンター×3**(`kpi_summary` から): 候補銘柄数(`total_candidates`) / 平均スコア(`avg_score`) / 最高スコア(`top_score`)
# MAGIC - **テーブル**(`candidates_ranking` から): 全カラムを表示、`rank` 昇順
# MAGIC - **棒グラフ**(`industry_summary` から): X軸=`industry_33`、Y軸=`candidate_count`
# MAGIC - **ヒストグラム**(`score_distribution` から): `score` 列を選択(自動ビニング)
# MAGIC - **散布図**(`quality_map` から): X軸=`gross_margin_pct`、Y軸=`fcf_margin_pct`、色/サイズ=`score`
# MAGIC - **KPIカウンター**(`last_updated` から): `last_updated`(直近更新日)
# MAGIC 6. 右上「**Publish**」でダッシュボードを公開する
# MAGIC 7. 公開後のブラウザURL(`.../dashboardsv3/<DASHBOARD_ID>`)から `DASHBOARD_ID` をコピーする
# COMMAND ----------
# MAGIC %md
# MAGIC ## 7-3. Dashboard ID の設定
# MAGIC
# MAGIC GUI で作成したダッシュボードの ID を下のセルに貼り付ける。
# COMMAND ----------
from databricks.sdk import WorkspaceClient
DASHBOARD_ID = '<あなたのDashboard ID>' # ★ 自分の環境の値に置き換える
w = WorkspaceClient()
dashboard = w.lakeview.get(DASHBOARD_ID)
print(f'Dashboard名 : {dashboard.display_name}')
print(f'Warehouse ID: {dashboard.warehouse_id}')
print(f'状態 : {dashboard.lifecycle_state}')
# COMMAND ----------
# MAGIC %md
# MAGIC ## 7-4. 動作確認
# MAGIC
# MAGIC 公開したダッシュボードの構成(データセット・ウィジェット数)を `serialized_dashboard` から確認する。
# COMMAND ----------
import json
spec = json.loads(dashboard.serialized_dashboard)
dataset_names = {d['name'] for d in spec.get('datasets', [])}
pages = spec.get('pages', [])
widget_count = sum(len(p.get('layout', [])) for p in pages)
print(f'データセット数: {len(dataset_names)} 件 → {sorted(dataset_names)}')
print(f'ページ数 : {len(pages)} 件')
print(f'ウィジェット数: {widget_count} 件')
v_candidates_referenced = any(
'v_candidates' in ''.join(d.get('queryLines', []) or [])
for d in spec.get('datasets', [])
)
print(f'v_candidates 参照 : {v_candidates_referenced}')
# COMMAND ----------
# MAGIC %md
# MAGIC ## 完了確認
# COMMAND ----------
print('=== 完了確認 ===')
assert DASHBOARD_ID, 'DASHBOARD_ID が未設定(GUIで作成したDashboardのIDを貼り付けること)'
assert dashboard.warehouse_id, 'DashboardにWarehouseが紐づいていない'
assert v_candidates_referenced, 'どのデータセットもv_candidatesを参照していない'
assert widget_count >= 5, f'ウィジェット数が不足({widget_count}件、5件以上を推奨)'
score60_count = spark.sql(
'SELECT COUNT(*) AS cnt FROM stock_analysis.gold.v_candidates WHERE score >= 60'
).collect()[0]['cnt']
print(f'スコア60点以上の銘柄数: {score60_count} 件')
assert score60_count >= 1, 'スコア60点以上の銘柄が1件も存在しない'
print('\n✅ v_candidates を参照するAI/BI Dashboardが作成され、公開されていることを確認')
print(f'✅ ウィジェット数: {widget_count} 件、スコア60点以上の銘柄: {score60_count} 件')
print('\n次のステップ → 週次ジョブの実行後、最新の screened_date が反映されているか確認する')
付録B — LLMOpsエージェント(agents/)
実践編の LLMOps 独自エージェント(第2部 第6章)で使う Python モジュールです。ノートブックから再利用可能なモジュールとして実装し、Workspace Files としてアップロードします。
agents/__init__.py
東証スクリーニング LLMOps 独自エージェント。
"""東証スクリーニング LLMOps 独自エージェント。
`screening_agent` モジュールに LangGraph ベースのエージェント本体と、
`gold.v_candidates` を参照する限定ツールを実装している。
"""
from .screening_agent import (
ScreeningAgent,
Thread,
SYSTEM_PROMPT,
search_candidates,
get_candidate_detail,
)
__all__ = [
"ScreeningAgent",
"Thread",
"SYSTEM_PROMPT",
"search_candidates",
"get_candidate_detail",
]
agents/screening_agent.py
東証スクリーニング結果に回答する LLMOps 独自エージェント(LangGraph)。
"""東証スクリーニング結果に回答する LLMOps 独自エージェント(LangGraph)。
Genie(自然言語→自由SQL)に対し、本エージェントは
`stock_analysis.gold.v_candidates` を参照する **限定ツール** だけを持ち、
長期保有(3〜5年)目線の投資所見を生成する。ツールをパラメータ化することで
誤った SQL 生成を防ぎ、MLflow トレーシング+LLM-as-a-judge 評価で品質を
継続検証できる状態にすることが狙い(=Genie では作り込めない "作り込み")。
## モジュールの使い方(notebook 側)
import sys, os
sys.path.append(os.path.abspath("../agents"))
from screening_agent import ScreeningAgent, Thread
agent = ScreeningAgent()
answer = agent.process_query("スコア70点以上で配当利回りが高い銘柄は?", Thread())
experiment の指定は呼び出し側 notebook に委ねる(本モジュールは
`mlflow.langchain.autolog()` を有効化するだけで記録先は決め打ちしない。
import 時に記録先を固定すると評価用の experiment と衝突するため)。
"""
from __future__ import annotations
import contextvars
import os
import uuid
from contextlib import contextmanager
from dataclasses import dataclass, field
from typing import List, Optional
import mlflow
import pandas as pd
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage
from langchain_core.tools import tool
from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import END, StateGraph
from langgraph.graph.message import MessagesState
from langgraph.prebuilt import ToolNode
from databricks_langchain import ChatDatabricks
# MLflowトレーシングを有効化(experimentの指定は呼び出し側notebookに委ねる)
mlflow.langchain.autolog()
# ── 設定(notebook 側から環境変数で差し替え可能)───────────────────────────
# 本体LLM: ツール呼び出しの信頼性のため 70b を既定にする。
# Free Edition 実測(2026-07-21): `databricks-meta-llama-3-1-70b-instruct` は存在せず、
# `databricks-meta-llama-3-3-70b-instruct` が実在しツール呼び出しも可。
LLM_ENDPOINT = os.environ.get("LLM_ENDPOINT", "databricks-meta-llama-3-3-70b-instruct")
# 参照する整形済みビュー(Genie と同じビューを共有する)。
CANDIDATES_TABLE = os.environ.get("CANDIDATES_TABLE", "stock_analysis.gold.v_candidates")
# Spark が無い環境(Model Serving コンテナ)で SQL 経由取得に使う SQL ウェアハウス。
# Free Edition の Serverless Starter Warehouse。notebook 側から差し替え可能。
WAREHOUSE_ID = os.environ.get("WAREHOUSE_ID", "<あなたのウェアハウスID>") # ★ 自分の環境の値に置き換える
# ── ツールが参照する候補データ(40件程度のため pandas 化してキャッシュ)────────
# ノートブック等、Spark があり実行者が1人の環境で使うモジュールレベルのキャッシュ。
_candidates_df = None
# ── リクエストスコープ ─────────────────────────────────────────────────────
# サービング時は、データ取得に使う WorkspaceClient をリクエストごとに組み立てて
# ここへ載せる。キャッシュもリクエスト単位にする。狙いは2つ。
# 1. 資格情報の作り方を1か所(chat_agent 側)に閉じ込め、ツールは受け取るだけにする
# 2. 週次バッチで v_candidates が更新されたとき、コンテナを再起動しなくても
# 次のリクエストから最新を読む(モジュールにキャッシュすると古いまま居座る)
_request_state: contextvars.ContextVar[Optional[dict]] = contextvars.ContextVar(
"screening_agent_request_state", default=None
)
@contextmanager
def request_scope(workspace_client):
"""1リクエストの間だけ、指定した WorkspaceClient を有効にする。
`chat_agent.ScreeningChatAgent.predict()` から使う。スコープを抜けると
クライアントもキャッシュも破棄される。
Args:
workspace_client: データ取得に使う認証済みの WorkspaceClient。
"""
token = _request_state.set({"client": workspace_client, "df": None})
try:
yield
finally:
_request_state.reset(token)
# 数値として扱う SQL 型(SQLウェアハウス経由取得時の型変換に使う)
_NUMERIC_SQL_TYPES = {
"INT", "INTEGER", "SHORT", "BYTE", "LONG", "BIGINT",
"FLOAT", "DOUBLE", "DECIMAL",
}
def _get_active_spark():
"""アクティブな SparkSession を返す(無ければ None)。
pyspark 自体が無いサービング環境でも import で落ちないよう遅延 import する。
"""
try:
from pyspark.sql import SparkSession
return SparkSession.getActiveSession()
except Exception:
return None
def _load_via_sql_warehouse(workspace_client=None) -> "pd.DataFrame":
"""SQL ウェアハウス経由で候補ビューを pandas DataFrame として取得する。
Model Serving のコンテナには Spark クラスタが無いため、`spark.table()` の代わりに
Databricks SDK の Statement Execution API で `SELECT` する。この API を叩くには
ワークスペースの `databricks-sql-access` エンタイトルメントとウェアハウスの `CAN_USE`
が要るため、サービング時は両方を付与した専用サービスプリンシパルのクライアントを渡す。
Args:
workspace_client: 使用する WorkspaceClient。サービング時は専用サービスプリンシパルで
組み立てたクライアントを渡す。省略時は既定の認証で組み立てる
(ノートブックからの単体検証用)。
"""
if workspace_client is None:
from databricks.sdk import WorkspaceClient
workspace_client = WorkspaceClient()
resp = workspace_client.statement_execution.execute_statement(
warehouse_id=WAREHOUSE_ID,
statement=f"SELECT * FROM {CANDIDATES_TABLE}",
wait_timeout="50s",
)
state = getattr(resp.status, "state", None)
if state is None or str(getattr(state, "value", state)) != "SUCCEEDED":
raise RuntimeError(f"候補ビューの取得に失敗しました(state={state}): {CANDIDATES_TABLE}")
columns = resp.manifest.schema.columns
names = [c.name for c in columns]
numeric = {
c.name
for c in columns
if str(getattr(c.type_name, "value", c.type_name)).upper() in _NUMERIC_SQL_TYPES
}
rows = (resp.result.data_array if resp.result else None) or []
df = pd.DataFrame(rows, columns=names)
for col in names:
if col in numeric:
df[col] = pd.to_numeric(df[col], errors="coerce")
return df
def _load_candidates() -> "pd.DataFrame":
"""`v_candidates` を pandas DataFrame として取得する。
候補は数十件と小さいため、都度クエリせず pandas に載せてツール内でフィルタする。
取得経路とキャッシュの寿命は実行環境で変わる。
- **サービング**: `request_scope()` の中で呼ばれる。渡されたクライアント
(専用サービスプリンシパル)で SQL ウェアハウスから読み、そのリクエストの間だけ
キャッシュする。
- **ノートブック(Spark あり)**: `spark.table(...)` で読み、モジュールレベルに
キャッシュする。最新に更新したい場合は `reset_candidates_cache()` を呼ぶ。
- **Spark も リクエストスコープも無い**: 既定の認証で SQL ウェアハウスから読む
(モジュール単体の動作確認用)。
"""
state = _request_state.get()
if state is not None:
# リクエストスコープ経路。キャッシュはこのリクエスト限り。
if state["df"] is None:
state["df"] = _load_via_sql_warehouse(state["client"])
return state["df"]
global _candidates_df
if _candidates_df is None:
spark = _get_active_spark()
if spark is not None:
_candidates_df = spark.table(CANDIDATES_TABLE).toPandas()
else:
_candidates_df = _load_via_sql_warehouse()
return _candidates_df
def reset_candidates_cache() -> None:
"""モジュールレベルのキャッシュを破棄する(データ更新後に最新を読み直すため)。
リクエストスコープ側のキャッシュはリクエストごとに作られて捨てられるため、
ここでの操作は不要。
"""
global _candidates_df
_candidates_df = None
# ── ツール定義(Genie との差別化=限定・パラメータ化されたツール)──────────────
@tool
def search_candidates(
min_score: int = 0,
industry: str = "",
max_per: float = 0.0,
min_dividend_yield: float = 0.0,
limit: int = 10,
) -> str:
"""スクリーニング通過銘柄を条件で検索する。
長期保有候補の一覧を、スコア・業種・バリュエーション・配当で絞り込む。
条件を満たす銘柄をスコア降順で返す。
Args:
min_score: 最低スコア(0〜100)。0 なら下限なし。
industry: 業種名の部分一致(東証33業種、例「情報・通信」「医薬品」)。空なら全業種。
max_per: PER の上限。0 以下なら上限なし。
min_dividend_yield: 配当利回り(%)の下限。0 以下なら下限なし。
limit: 返す最大件数。
Returns:
条件に合致した銘柄の要約テキスト(スコア降順)。
"""
df = _load_candidates().copy()
if min_score:
df = df[df["score"] >= min_score]
if industry:
# 業種は東証33業種の正規名(例「機械」)で保持しているが、モデルは
# 「機械セクター」「機械業種」のように渡すことがある。サフィックスを落とし
# 双方向に部分一致させて、正規名との表記揺れを吸収する(片方向 contains だと
# 「機械」を含む "機械セクター" を渡されたとき正規名「機械」に当たらず0件になる)。
key = industry.strip()
for _suf in ("セクター", "セクタ", "業種", "業界"):
if key.endswith(_suf) and len(key) > len(_suf):
key = key[: -len(_suf)]
break
ind = df["industry_33"].astype(str)
df = df[ind.str.contains(key, na=False) | ind.apply(lambda v: bool(v) and v in key)]
if max_per and max_per > 0:
df = df[df["per"].notna() & (df["per"] <= max_per)]
if min_dividend_yield and min_dividend_yield > 0:
df = df[df["dividend_yield_pct"].notna() & (df["dividend_yield_pct"] >= min_dividend_yield)]
df = df.sort_values("score", ascending=False).head(int(limit) if limit else 10)
if df.empty:
return "条件に合致するスクリーニング通過銘柄はありませんでした。"
lines = [f"該当 {len(df)} 件(スコア降順):"]
for _, r in df.iterrows():
lines.append(
f"- {r['name']}({r['code']} / {r['industry_33']}): "
f"スコア{_fmt(r['score'], 0)} / PER{_fmt(r['per'], 1)} / PBR{_fmt(r['pbr'], 2)} / "
f"配当{_fmt(r['dividend_yield_pct'], 2)}% / ROE{_fmt(r['roe_pct'], 1)}% / "
f"粗利益率{_fmt(r['gross_margin_pct'], 1)}% / 12ヶ月{_fmt(r['momentum_12m_pct'], 1)}%"
)
return "\n".join(lines)
@tool
def get_candidate_detail(code: str) -> str:
"""指定した証券コードの銘柄について、全指標と既存AIコメントを取得する。
1銘柄の長期評価を掘り下げるときに使う。収益品質・成長性・バリュエーション・
財務健全性・長期トレンドの各指標と、スコアリング時に生成済みのAIコメントを返す。
Args:
code: 証券コード(例「7203」)。
Returns:
銘柄の詳細指標テキスト。通過銘柄でない場合はその旨を返す。
"""
df = _load_candidates()
row = df[df["code"].astype(str) == str(code).strip()]
if row.empty:
return (
f"証券コード {code} はスクリーニング通過銘柄に含まれていません"
"(条件を満たさなかったか、対象外の銘柄です)。"
)
r = row.iloc[0]
return (
f"【{r['name']}({r['code']} / {r['industry_33']} / {r['market_segment']})】\n"
f"総合スコア: {_fmt(r['score'], 0)}/100(ランク {_fmt(r['rank'], 0)})\n"
f"株価: {_fmt(r['current_price'], 0)}円\n"
f"[収益品質] 粗利益率 {_fmt(r['gross_margin_pct'], 1)}% / "
f"FCFマージン {_fmt(r['fcf_margin_pct'], 1)}% / ROA {_fmt(r['roa_pct'], 1)}%\n"
f"[成長性] 売上成長率 {_fmt(r['revenue_growth_pct'], 1)}% / "
f"利益成長率 {_fmt(r['earnings_growth_pct'], 1)}%\n"
f"[バリュエーション] ROE {_fmt(r['roe_pct'], 1)}% / PER {_fmt(r['per'], 1)} / "
f"PBR {_fmt(r['pbr'], 2)} / 配当利回り {_fmt(r['dividend_yield_pct'], 2)}%\n"
f"[財務健全性] D/E {_fmt(r['debt_to_equity'], 0)} / 流動比率 {_fmt(r['current_ratio'], 1)}\n"
f"[長期トレンド] 12ヶ月モメンタム {_fmt(r['momentum_12m_pct'], 1)}%\n"
f"[営業利益率] {_fmt(r['operating_margin_pct'], 1)}%\n"
f"[既存AIコメント] {r.get('ai_comment', '—')}"
)
def _fmt(v, digits: int) -> str:
"""None/NaN を安全に整形するヘルパー。"""
try:
if v is None:
return "N/A"
f = float(v)
if f != f: # NaN
return "N/A"
return f"{f:.{digits}f}"
except (TypeError, ValueError):
return str(v)
TOOLS = [search_candidates, get_candidate_detail]
# ── システムプロンプト ─────────────────────────────────────────────────────
SYSTEM_PROMPT = """あなたは東証上場株の中長期投資候補を分析するアシスタントです。
利用者は3〜5年の長期保有を前提に、スクリーニングを通過した候補銘柄について質問します。
あなたの責務:
- 提供されたツールを使ってスクリーニング通過銘柄の情報を取得し、事実に基づいて回答する
- 一時的な株価変動ではなく、ビジネスモデルの持続性・競争優位性・財務健全性を重視する
必ず守ること:
- 数値や銘柄の情報は、必ずツールから取得した結果に基づいて述べる(推測で数値を作らない)
- **ツールの結果に登場しない企業名・製品名・事業内容を持ち出さない**。例え話や補足であっても、
ツールから得ていない固有名詞(実在・架空を問わず)を創作して論評してはならない
- スクリーニング通過銘柄に含まれない銘柄について問われたら、その旨を正直に伝える
- **ユーザーが明示した条件だけをツールに渡す**。ユーザーが指定していないスコア・PER・配当利回り・業種等の
絞り込みを勝手に追加しない(「機械の候補は?」に対して勝手に「スコア60以上・配当2%以上」等を付け足さない)
- 実際にツールへ渡して絞り込んだ条件だけを述べる。絞っていない指標を「〜という条件で選ばれています」
「PER・ROE等も一定水準を維持」等と、さも絞り込んだかのように装わない
- **「最も〜」「一番〜」等の比較・序列は、ツール結果に含まれる数値だけで判定する**。手元の一覧で
比較できるなら必ず答える(「情報を提供できない」と逃げない)。必要なら追加でツールを呼んで値を揃える
- **リスク要因への言及を必ず含める**。1銘柄を深掘りする場合はその銘柄の主なリスク
(業種サイクル・債務・競合・成長鈍化など)を述べる。複数銘柄を一覧で答える場合も、
回答の末尾に「留意点」として長期保有で共通して注意すべきリスクを最低1文添える。
- 回答の最後に「本内容は投資判断の参考情報であり、投資助言・推奨ではありません。」という
免責の一文を必ず添える
"""
# ── 会話スレッド(マルチターン用の軽量な入れ物)─────────────────────────────
@dataclass
class Thread:
"""会話スレッド。`id` は LangGraph の checkpointer のキーに使う。
実際の会話履歴は MemorySaver が `thread_id` ごとに保持するため、
`messages` は「システムプロンプトを既に注入したか」の判定用の軽い記録。
"""
id: str = field(default_factory=lambda: str(uuid.uuid4()))
messages: List[dict] = field(default_factory=list)
# ── エージェント本体 ───────────────────────────────────────────────────────
class ScreeningAgent:
"""スクリーニング結果に回答する LangGraph エージェント。
`process_query(query, thread)` を統一インターフェースとして提供する
(呼び出し側は内部のフレームワークに依存しない)。
"""
def __init__(self, system_prompt: Optional[str] = None):
"""エージェントを初期化する。
Args:
system_prompt: 使用するシステムプロンプト。省略時はモジュール定数の
SYSTEM_PROMPT を使う(将来 Prompt Registry から差し込めるようにする注入口)。
"""
self.system_prompt = system_prompt or SYSTEM_PROMPT
self.tools = TOOLS
self.executor = self._build_graph()
print(f"ScreeningAgent を初期化しました(ツール数: {len(self.tools)}):")
for t in self.tools:
print(f" - {t.name}")
def _tools_condition(self, messages: List[BaseMessage]) -> str:
"""最後のAIメッセージにツール呼び出しがあれば 'tools'、なければ 'end'。"""
if messages:
last = messages[-1]
if isinstance(last, AIMessage) and getattr(last, "tool_calls", []):
return "tools"
return "end"
def _build_graph(self):
"""agent ⇄ tools の LangGraph を構築してコンパイルする。"""
model = ChatDatabricks(endpoint=LLM_ENDPOINT)
model_with_tools = model.bind_tools(self.tools)
def call_model(state: MessagesState):
response = model_with_tools.invoke(state["messages"])
return {"messages": [response]}
graph = StateGraph(MessagesState)
graph.add_node("agent", call_model)
graph.add_node("tools", ToolNode(self.tools))
graph.add_conditional_edges(
"agent",
lambda state: self._tools_condition(state["messages"]),
{"tools": "tools", "end": END},
)
graph.add_edge("tools", "agent")
graph.set_entry_point("agent")
# MemorySaver で thread_id ごとに会話履歴を保持(マルチターン対応)
return graph.compile(checkpointer=MemorySaver())
def _extract_last_ai_message(self, messages) -> Optional[AIMessage]:
for msg in reversed(messages):
if isinstance(msg, AIMessage):
return msg
return None
def process_query(self, query: str, thread: Thread) -> str:
"""ユーザーのクエリをエージェントで処理して回答文字列を返す。
Args:
query: ユーザーの入力メッセージ
thread: コンテキストを維持するための会話スレッド
Returns:
エージェントの回答(文字列)
"""
incoming_messages: List[BaseMessage] = []
# 新しい会話の場合のみシステムプロンプトを先頭に注入する
if not thread.messages:
incoming_messages.append(SystemMessage(content=self.system_prompt))
thread.messages.append({"role": "system", "content": self.system_prompt})
incoming_messages.append(HumanMessage(content=query))
thread.messages.append({"role": "user", "content": query})
config = {"configurable": {"thread_id": thread.id}}
result_state = self.executor.invoke({"messages": incoming_messages}, config=config)
ai_message = self._extract_last_ai_message(result_state["messages"])
if ai_message is None:
raise RuntimeError("エージェントからの回答がありませんでした。")
thread.messages.append({"role": "assistant", "content": ai_message.content})
return ai_message.content
agents/chat_agent.py
Mosaic AI Agent Framework 用の ChatAgent ラッパー。
"""Mosaic AI Agent Framework 用の ChatAgent ラッパー。
`ScreeningAgent`(LangGraph)を `mlflow.pyfunc.ChatAgent` インターフェースに
適合させ、Model Serving にデプロイ可能にする。チャット形式(Playground / OpenAI 互換 API)で
呼び出せるようにするのが目的。
## プロンプトの扱い(案①: デプロイ時に読み込む)
システムプロンプトは Unity Catalog の Prompt Registry で管理するが、**読み込みはデプロイ時**に行う。
デプロイノートブック(`06c_deploy_agent.py`)が `@production` のテンプレートを解決し、
`log_model(model_config={"system_prompt": ...})` でモデルへ焼き込む。本モジュールは
`ModelConfig` からそれを読むだけで、**サービング実行時に UC プロンプトへはアクセスしない**。
代償として、エイリアスを別バージョンに向けたら**再デプロイが必要**。リクエスト毎に読み込む構成に
すれば再デプロイなしで切り替えられる(トレードオフ。本記事では「プロンプトを『いつ』読むか」のコラムで比べている)。
## 認証の扱い(専用サービスプリンシパル)
データ取得は **本プロジェクト専用のサービスプリンシパル `stock-analysis-agent-sp`** の資格情報で行う。
`predict()` の中で `DATABRICKS_CLIENT_ID` / `DATABRICKS_CLIENT_SECRET`(デプロイ時に
`agents.deploy(environment_vars=...)` からシークレット参照で注入)を読み、OAuth M2M の
`WorkspaceClient` を組み立てて `screening_agent.request_scope()` に載せる。
### なぜ自動認証パススルーだけでは足りないのか
当初は自動認証パススルー(`log_model(resources=[...])`)だけで足りると考えていた。しかし
**パススルーが渡すのはリソース単位の権限(ウェアハウスの Use Endpoint、テーブルの SELECT)であって、
`databricks-sql-access` のようなワークスペースのエンタイトルメントは渡らない**。
デプロイ時に Databricks が自動生成するサービスプリンシパルがその資格を持たないと、リソースをいくら
宣言しても Statement Execution API が `PermissionDenied` で落ちる(2026-08-15 に実際に発生)。
自動生成されるサービスプリンシパルは Databricks が所有・ローテーションする対象で、こちらから
エンタイトルメントを管理できない。そこで**自分で所有する SP** を立て、必要な資格
(`databricks-sql-access`、ウェアハウスの CAN_USE、UC の SELECT)を明示的に付与する。
権限の所在が1つのアカウントに集約され、必要なものだけを自分で足せるのが利点。
**注**: 資格情報の組み立ては `__init__` ではなく `predict()` で行う。リクエストの外で
クライアントを持ち回らず、スコープの寿命をリクエストに揃えるため。
"""
import os
from contextlib import nullcontext
import mlflow
from mlflow.models import ModelConfig
# 設定はデプロイ時に log_model(model_config=...) で焼き込む。
# development_config はローカル/デプロイ前検証時のフォールバック。
_config = ModelConfig(
development_config={
"system_prompt": None, # None → screening_agent の SYSTEM_PROMPT を使う
"llm_endpoint": "databricks-meta-llama-3-3-70b-instruct",
"candidates_table": "stock_analysis.gold.v_candidates",
"warehouse_id": "<あなたのウェアハウスID>", # ★ 自分の環境の値に置き換える
}
)
# screening_agent はモジュール定数(LLM_ENDPOINT 等)を import 時に os.environ から読むため、
# import より前に環境変数を設定しておく。
os.environ.setdefault("LLM_ENDPOINT", _config.get("llm_endpoint"))
os.environ.setdefault("CANDIDATES_TABLE", _config.get("candidates_table"))
os.environ.setdefault("WAREHOUSE_ID", _config.get("warehouse_id"))
import uuid
from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage, SystemMessage
from mlflow.models import set_model
from mlflow.pyfunc import ChatAgent
from mlflow.types.agent import ChatAgentChunk, ChatAgentMessage, ChatAgentResponse
from screening_agent import ScreeningAgent, SYSTEM_PROMPT, request_scope
# デプロイ時に解決済みのプロンプトがあればそれを、無ければモジュール定数を使う。
_SYSTEM_PROMPT = _config.get("system_prompt") or SYSTEM_PROMPT
def _data_request_scope():
"""データ取得用のクライアントを組み立て、リクエストスコープに載せる。
サービング時は `agents.deploy(environment_vars=...)` が注入した専用 SP の
OAuth 資格情報を使う。ノートブック等サービング外では環境変数が無いため
何もしないコンテキストを返し、`screening_agent` 側の既定経路
(Spark があれば `spark.table`、無ければ既定認証の SQL ウェアハウス)へ委ねる。
"""
client_id = os.environ.get("DATABRICKS_CLIENT_ID")
client_secret = os.environ.get("DATABRICKS_CLIENT_SECRET")
if not client_id or not client_secret:
return nullcontext()
try:
from databricks.sdk import WorkspaceClient
# host は SDK が DATABRICKS_HOST から解決する。client_id/secret を明示することで
# サービングの既定資格情報(自動生成 SP)ではなく専用 SP を確実に使う。
return request_scope(WorkspaceClient(client_id=client_id, client_secret=client_secret))
except Exception as e:
print(f"サービスプリンシパルのクライアントを作成できませんでした。既定の認証を使います: {e}")
return nullcontext()
class ScreeningChatAgent(ChatAgent):
"""`ScreeningAgent` を ChatAgent として公開するラッパー。"""
def __init__(self):
self._agent = ScreeningAgent(system_prompt=_SYSTEM_PROMPT)
def _to_lc_messages(self, messages):
"""会話履歴全体を LangChain メッセージへ変換(マルチターン対応)。"""
lc = [SystemMessage(content=_SYSTEM_PROMPT)]
for m in messages:
if m.role == "user":
lc.append(HumanMessage(content=m.content))
elif m.role == "assistant":
lc.append(AIMessage(content=m.content))
return lc
def predict(self, messages, context=None, custom_inputs=None):
lc = self._to_lc_messages(messages)
# リクエストごとに独立した thread_id でコンパイル済みグラフを実行
config = {"configurable": {"thread_id": str(uuid.uuid4())}}
# ツールのデータ取得はこのスコープの中でだけ専用 SP の資格情報を使う
with _data_request_scope():
result = self._agent.executor.invoke({"messages": lc}, config=config)
answer = ""
for msg in reversed(result["messages"]):
if isinstance(msg, AIMessage):
answer = msg.content
break
return ChatAgentResponse(
messages=[ChatAgentMessage(role="assistant", content=answer, id=str(uuid.uuid4()))]
)
def predict_stream(self, messages, context=None, custom_inputs=None):
# predict_stream が無いと Review App / Playground が毎回
# 「ストリーミング失敗→非ストリーミングで再試行」を表示する。LLM のトークンを逐次返す。
lc = self._to_lc_messages(messages)
config = {"configurable": {"thread_id": str(uuid.uuid4())}}
msg_id = str(uuid.uuid4())
streamed = False
# ストリーミング中もツールが動くため、生成が終わるまでスコープを開けておく
with _data_request_scope():
for chunk, _meta in self._agent.executor.stream(
{"messages": lc}, config=config, stream_mode="messages"
):
# 最終回答のトークンだけを流す(ツール判断ターンは content が空なのでスキップ)
if isinstance(chunk, AIMessageChunk) and chunk.content:
streamed = True
yield ChatAgentChunk(
delta=ChatAgentMessage(role="assistant", content=chunk.content, id=msg_id)
)
# トークンが取れなかった場合は一括応答をフォールバックで返す(表示崩れ防止)
if not streamed:
for msg in self.predict(messages, context, custom_inputs).messages:
yield ChatAgentChunk(delta=msg)
set_model(ScreeningChatAgent())