J-Quants APIでPER・PBRを取得|V2対応スクリーニング

Python実装・コード

先月、トヨタの決算を追いかけていたんですが、気づいたら子供のお風呂タイムになっていて。決算内容を半分しか読めないまま終わってしまいました。「割安株のスクリーニングを自動化すれば毎回追いかけなくても済むのでは?」と思ったのがこの記事を書くきっかけです。

ただ、作り始めてすぐに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銘柄の所要時間の目安
Free5財務+株価で約4分
Light60約20秒
Standard120約10秒
Premium500数秒

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桁になったこと。レスポンスがdatapagination_keyの形になったこと。値が文字列で空文字も返ること。そしてPER・PBRは自分で組み立てる必要があること。

Freeプランは1分5リクエストなので、銘柄ループを回すなら時間がかかります。逆に言えば、待ち時間の間に子供を寝かしつけられるので、僕の生活リズムには合っていました。次はこの結果を通知に流す予定です。仕組みはニュース通知の記事で作ったものをそのまま使い回せます。

タイトルとURLをコピーしました