先月、トヨタの決算を追いかけていたんですが、気づいたら子供のお風呂タイムになっていて。決算内容を半分しか読めないまま終わってしまいました。「割安株のスクリーニングを自動化すれば毎回追いかけなくても済むのでは?」と思ったのがこの記事を書くきっかけです。
ただ、作り始めてすぐに2つの壁にぶつかりました。1つ目は、ネット上のJ-Quantsのサンプルコードがほぼ全部動かないこと。認証方式がV1のトークン方式からV2のAPIキー方式に変わり、V1は閉鎖されました。token/auth_userでリフレッシュトークンを取る記事は、いま写経しても401が返ります。
2つ目は、J-Quants APIがPERもPBRも返してくれないこと。返ってくるのはEPSや純資産といった素材で、指標は自分で組み立てる必要があります。latest.get("PER")のようなコードは、存在しないキーを取りに行っているので永遠にNoneです。
この記事では、V2で認証し、財務と株価を取得し、PER・PBRを自前で計算してスクリーニングするところまでを書きます。無料プランのレート制限に合わせた設計も含めます。
📘 外部参考:V1からV2への変更点(公式) / 財務情報の仕様(公式)
V2のセットアップ
アカウントを作り、プランを選択してから、ダッシュボードでAPIキーを発行します。プラン選択を完了しないとAPIが有効にならないので、そこで止まりがちです。
pip install requests pandas
公式クライアントを使う手もありますが、V2はx-api-keyヘッダーを付けるだけなので、requestsで十分です。ライブラリのバージョン差で悩まずに済みます。
import os
import time
import requests
import pandas as pd
BASE = "https://api.jquants.com/v2"
API_KEY = os.getenv("JQUANTS_API_KEY") # .env に置き、gitignore する
class JQuants:
"""Freeプランのレート制限(5リクエスト/分)を守るクライアント。"""
def __init__(self, api_key: str, per_minute: int = 5):
if not api_key:
raise RuntimeError("JQUANTS_API_KEY が未設定です")
self.s = requests.Session()
self.s.headers.update({"x-api-key": api_key})
self.interval = 60.0 / per_minute
self._last = 0.0
def get(self, path: str, **params) -> list[dict]:
rows, key = [], None
while True:
wait = self.interval - (time.time() - self._last)
if wait > 0:
time.sleep(wait)
if key:
params["pagination_key"] = key
r = self.s.get(f"{BASE}{path}", params=params, timeout=30)
self._last = time.time()
if r.status_code == 429:
time.sleep(20)
continue
r.raise_for_status()
body = r.json()
rows += body.get("data", [])
key = body.get("pagination_key")
if not key:
return rows
cli = JQuants(API_KEY)
V2ではレスポンスがdataキーの配列に統一され、pagination_keyで続きを取る形になりました。これを処理していないコードは、件数が多いときに黙って先頭ページだけを返します。件数が想定より少ないときは、まずここを疑ってください。
レート制限が設計を決める
| プラン | 上限(リクエスト/分) | 10銘柄の所要時間の目安 |
|---|---|---|
| Free | 5 | 財務+株価で約4分 |
| Light | 60 | 約20秒 |
| Standard | 120 | 約10秒 |
| Premium | 500 | 数秒 |
Freeは1分に5回です。銘柄ごとにループを回すと、20銘柄で8分かかります。日付指定で全銘柄まとめて取る方が圧倒的に速いので、後述のやり方に切り替えました。
銘柄コードは5桁
V2では銘柄コードが5桁です。トヨタは7203ではなく72030。普通株は末尾に0が付きます。ここを間違えると、エラーにならず空配列が返ってくるので気づきにくいです。
MAKERS = {
"72030": "トヨタ自動車",
"65010": "日立製作所",
"65030": "三菱電機",
"63010": "小松製作所",
"54010": "日本製鉄",
"70110": "三菱重工業",
"67520": "パナソニックHD",
}
def listed_master(cli: JQuants) -> pd.DataFrame:
"""上場銘柄一覧。業種で絞りたいときに使う。"""
return pd.DataFrame(cli.get("/equities/master"))
# master = listed_master(cli)
# print(master[master["Code"].isin(MAKERS)][["Code", "CompanyName"]])
財務情報を取得する
財務情報は/v2/fins/summaryです。V1の/v1/fins/statementsから名前が変わっています。
def to_num(v) -> float | None:
"""J-Quantsの数値は文字列。空文字も普通に返ってくる。"""
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def fetch_financials(cli: JQuants, code: str) -> dict | None:
rows = cli.get("/fins/summary", code=code)
if not rows:
return None
df = pd.DataFrame(rows).sort_values("DiscDate")
latest = df.iloc[-1].to_dict()
# 通期実績(FY)の直近も押さえておく
fy = df[df["CurPerType"] == "FY"]
latest_fy = fy.iloc[-1].to_dict() if not fy.empty else {}
return {"latest": latest, "fy": latest_fy}
ここで押さえるべき点が3つあります。
- 値はすべて文字列で返る。
float()に通す前に空文字を弾きます。 - 空文字が頻繁に入る。実際、四半期開示では
BPSが空のことがよくあります。 - 四半期のEPSは期間累計。3Q開示の
EPSは9か月ぶんです。これを株価で割ってもPERにはなりません。
3つ目が地味に効きます。年換算するか、会社予想の通期EPS(FEPS)を使うのが実務的です。決算をまたぐたびに指標が跳ねる原因は、たいていこれでした。
株価を取得してPER・PBRを組み立てる
株価は/v2/equities/bars/daily。カラム名が短縮され、終値はC、調整後終値はAdjCです。
def fetch_price(cli: JQuants, code: str) -> float | None:
rows = cli.get("/equities/bars/daily", code=code)
if not rows:
return None
df = pd.DataFrame(rows).sort_values("Date")
for col in ("C", "AdjC"):
if col in df.columns:
v = to_num(df.iloc[-1][col])
if v:
return v
return None
def build_metrics(code: str, name: str, fin: dict, price: float) -> dict:
latest, fy = fin["latest"], fin["fy"]
# 予想EPS優先、なければ通期実績EPS
eps = to_num(latest.get("FEPS")) or to_num(fy.get("EPS"))
# BPSは空のことがあるので、純資産÷(発行済株式数−自己株式数)で補完
bps = to_num(latest.get("BPS")) or to_num(fy.get("BPS"))
if bps is None:
eq = to_num(latest.get("Eq")) or to_num(fy.get("Eq"))
sh = to_num(latest.get("ShOutFY")) or to_num(fy.get("ShOutFY"))
tr = to_num(latest.get("TrShFY")) or to_num(fy.get("TrShFY")) or 0.0
if eq and sh and (sh - tr) > 0:
bps = eq / (sh - tr)
return {
"code": code,
"name": name,
"price": round(price, 1),
"eps": round(eps, 2) if eps else None,
"bps": round(bps, 2) if bps else None,
"per": round(price / eps, 2) if eps and eps > 0 else None,
"pbr": round(price / bps, 2) if bps and bps > 0 else None,
"payout": to_num(latest.get("FPayoutRatioAnn")),
"disc_date": latest.get("DiscDate"),
"period": latest.get("CurPerType"),
"eps_source": "予想" if to_num(latest.get("FEPS")) else "実績",
}
eps_sourceを残しているのがポイントです。予想PERと実績PERは別物なので、混ざった表で「PERが低い順」に並べると意味がなくなります。どちらで計算したかを列に持たせておけば、後から気づけます。
EPSがマイナス(赤字)のときにPERを計算しないようにしているのも重要です。赤字企業のPERは負になり、「PER 15未満」のフィルタに引っかかってしまいます。
スクリーニングを回す
def screen(cli: JQuants, targets: dict[str, str],
max_per: float = 15.0, max_pbr: float = 1.2) -> pd.DataFrame:
rows = []
for code, name in targets.items():
try:
fin = fetch_financials(cli, code)
if not fin:
print(f"{code} {name}: 財務データなし")
continue
price = fetch_price(cli, code)
if not price:
print(f"{code} {name}: 株価なし")
continue
rows.append(build_metrics(code, name, fin, price))
except requests.HTTPError as e:
print(f"{code} {name}: HTTPエラー {e.response.status_code}")
except Exception as e:
print(f"{code} {name}: {type(e).__name__} {e}")
df = pd.DataFrame(rows)
if df.empty:
return df
df["cheap"] = (df["per"] < max_per) & (df["pbr"] < max_pbr)
return df.sort_values("per", na_position="last")
result = screen(cli, MAKERS)
print(result.to_string(index=False))
フィルタで絞り込むのではなく、cheapという列を足しているだけなのに注目してください。除外した銘柄も表に残すと、「なぜ落ちたか」がその場で分かります。PERが取れなかったのか、赤字なのか、単に高いのか。除外してしまうと、この区別が消えます。
スクリーニング結果の解釈
PERが低いほど割安、PBRが1.0を下回ると「解散価値以下」と言われます。製造メーカーはキャッシュリッチな企業が多く、PBR1.0以下が意外と見つかります。
ただし注意点があります。PERが低い理由が「一過性の利益増」の場合です。僕も最初にこれで釣られて日本製鉄を買い増しして、後で特別利益込みの数字だったと気づいたことがあります。fins/summaryには営業利益(OP)も入っているので、売上と営業利益の推移を並べて確認するようにしました。
def profit_trend(cli: JQuants, code: str) -> pd.DataFrame:
rows = cli.get("/fins/summary", code=code)
df = pd.DataFrame(rows)
fy = df[df["CurPerType"] == "FY"].copy()
for c in ("Sales", "OP", "NP"):
fy[c] = fy[c].map(to_num)
fy["op_margin"] = (fy["OP"] / fy["Sales"] * 100).round(2)
fy["np_over_op"] = (fy["NP"] / fy["OP"]).round(2) # 1を大きく超える年は特別利益を疑う
return fy[["CurFYEn", "Sales", "OP", "NP", "op_margin", "np_over_op"]].tail(5)
np_over_opが1.5や2を超えている年は、営業利益より純利益が大きい状態です。資産売却益などの一過性要因が入っている可能性が高く、その年のEPSで計算したPERは翌年には通用しません。
定期実行と保存
from datetime import date
from pathlib import Path
def save(df: pd.DataFrame, outdir: str = "screening") -> Path:
Path(outdir).mkdir(exist_ok=True)
path = Path(outdir) / f"screening_{date.today():%Y%m%d}.csv"
df.to_csv(path, index=False, encoding="utf-8-sig")
print(f"保存: {path}")
return path
if __name__ == "__main__":
df = screen(cli, MAKERS)
if not df.empty:
save(df)
hits = df[df["cheap"]]
print(f"条件一致: {len(hits)} / {len(df)} 銘柄")
Windowsのタスクスケジューラに登録して、毎週月曜の朝に走らせています。utf-8-sigで保存しているのでExcelで文字化けしません。過去分が日付つきで残るので、PERの推移を後から追えるのも便利でした。
APIキーはos.getenvで読み、.envは.gitignoreに入れています。V2のAPIキーには有効期限がないので、漏れたときの影響はV1のトークンより大きいです。
まとめ
J-Quants APIで割安株スクリーニングを自動化できました。ただ、実際に作って分かったのは、公式APIだからといって指標が揃っているわけではないという点です。
ハマりどころは5つでした。V1のトークン認証は廃止されx-api-key方式になったこと。銘柄コードが5桁になったこと。レスポンスがdataとpagination_keyの形になったこと。値が文字列で空文字も返ること。そしてPER・PBRは自分で組み立てる必要があること。
Freeプランは1分5リクエストなので、銘柄ループを回すなら時間がかかります。逆に言えば、待ち時間の間に子供を寝かしつけられるので、僕の生活リズムには合っていました。次はこの結果を通知に流す予定です。仕組みはニュース通知の記事で作ったものをそのまま使い回せます。

