From 50b28d1239ae99d5f655674a0f7b5235a521d85d Mon Sep 17 00:00:00 2001 From: saikioshima Date: Mon, 8 Jun 2026 19:19:45 +0900 Subject: [PATCH 1/4] =?UTF-8?q?=E8=B2=A1=E5=8B=99=E6=83=85=E5=A0=B1?= =?UTF-8?q?=E3=83=BB=E8=B2=A1=E5=8B=99=E8=AB=B8=E8=A1=A8API=E3=81=ABcursor?= =?UTF-8?q?=E3=83=91=E3=83=A9=E3=83=A1=E3=83=BC=E3=82=BF=E3=82=92=E4=BD=BF?= =?UTF-8?q?=E3=81=A3=E3=81=9F=E5=B7=AE=E5=88=86=E5=8F=96=E5=BE=97=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jquantsapi/apis/v2/fins.py | 67 +++++++++++++++------ jquantsapi/client_v2.py | 65 +++++++++++++++++++- tests/test_client_v2.py | 120 ++++++++++++++++++++++++++++++++++++- 3 files changed, 230 insertions(+), 22 deletions(-) diff --git a/jquantsapi/apis/v2/fins.py b/jquantsapi/apis/v2/fins.py index efa9559..cb39d3e 100644 --- a/jquantsapi/apis/v2/fins.py +++ b/jquantsapi/apis/v2/fins.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any +from typing import Any, Optional import pandas as pd # type: ignore @@ -22,24 +22,40 @@ def execute( *, code: str = "", date_yyyymmdd: str = "", + cursor: str = "", **kwargs: Any, - ) -> pd.DataFrame: + ) -> tuple[pd.DataFrame, Optional[str]]: """ - `/fins/summary` を実行し、財務情報サマリを DataFrame で返す。 + v2 `/fins/summary` を実行し、財務情報サマリと cursor を返す。 """ + url = f"{client.JQUANTS_API_BASE}/fins/summary" # type: ignore[attr-defined] + params: dict[str, Any] = {} if code: params["code"] = code if date_yyyymmdd: params["date"] = date_yyyymmdd + if cursor: + params["cursor"] = cursor - all_data = client._get_paginated( # type: ignore[attr-defined] - "/fins/summary", - params=params, - ) + all_data: list[dict[str, Any]] = [] + returned_cursor: Optional[str] = None + query = dict(params) + while True: + resp = client._get(url, query) # type: ignore[attr-defined] + payload = resp.json() + all_data.extend(payload.get("data", [])) + returned_cursor = payload.get("cursor") + + pagination_key = payload.get("pagination_key") + if not pagination_key: + break + query["pagination_key"] = pagination_key + + cols = constants.FIN_SUMMARY_COLUMNS_V2 if not all_data: - return pd.DataFrame() + return pd.DataFrame(columns=cols), returned_cursor df = pd.DataFrame.from_records(all_data) for col in ( @@ -57,9 +73,7 @@ def execute( if sort_cols: df.sort_values(sort_cols, inplace=True) - # v1 `/fins/statements` と同様に、定義済みカラムの順序で返す - cols = constants.FIN_SUMMARY_COLUMNS_V2 - return df[cols].reset_index(drop=True) + return df[cols].reset_index(drop=True), returned_cursor class FinDetailsApiV2(BaseApi): @@ -76,24 +90,39 @@ def execute( *, code: str = "", date_yyyymmdd: str = "", + cursor: str = "", **kwargs: Any, - ) -> pd.DataFrame: + ) -> tuple[pd.DataFrame, Optional[str]]: """ - `/fins/details` を実行し、財務諸表詳細を DataFrame で返す。 + v2 `/fins/details` を実行し、財務諸表詳細と cursor を返す。 """ + url = f"{client.JQUANTS_API_BASE}/fins/details" # type: ignore[attr-defined] + params: dict[str, Any] = {} if code: params["code"] = code if date_yyyymmdd: params["date"] = date_yyyymmdd + if cursor: + params["cursor"] = cursor - all_data = client._get_paginated( # type: ignore[attr-defined] - "/fins/details", - params=params, - ) + all_data: list[dict[str, Any]] = [] + returned_cursor: Optional[str] = None + query = dict(params) + + while True: + resp = client._get(url, query) # type: ignore[attr-defined] + payload = resp.json() + all_data.extend(payload.get("data", [])) + returned_cursor = payload.get("cursor") + + pagination_key = payload.get("pagination_key") + if not pagination_key: + break + query["pagination_key"] = pagination_key if not all_data: - return pd.DataFrame() + return pd.DataFrame(), returned_cursor df = pd.DataFrame.from_records(all_data) if "DiscDate" in df.columns: @@ -101,7 +130,7 @@ def execute( sort_cols = [c for c in ["DiscDate", "DiscTime", "Code"] if c in df.columns] if sort_cols: df.sort_values(sort_cols, inplace=True) - return df.reset_index(drop=True) + return df.reset_index(drop=True), returned_cursor class FinDividendApiV2(BaseApi): diff --git a/jquantsapi/client_v2.py b/jquantsapi/client_v2.py index f69f6b8..04a5514 100644 --- a/jquantsapi/client_v2.py +++ b/jquantsapi/client_v2.py @@ -17,6 +17,11 @@ else: import tomli as tomllib +if sys.version_info >= (3, 13): + from warnings import deprecated # type: ignore[attr-defined] +else: + from typing_extensions import deprecated + from jquantsapi import __version__, constants from jquantsapi.apis.v2.bulk import BulkGetApiV2, BulkListApiV2 from jquantsapi.apis.v2.derivatives import ( @@ -600,6 +605,9 @@ def get_eq_investor_types( # ------------------------------------------------------------------ # /fins/summary (path_old: /fins/statements) # ------------------------------------------------------------------ + @deprecated( + "get_fin_summary_cursor() is now available for cursor-based incremental retrieval. Consider using it instead." + ) def get_fin_summary( self, code: str = "", @@ -614,11 +622,12 @@ def get_fin_summary( Returns: pd.DataFrame: 財務情報 (v2のフィールド名で返却) """ - return self._fin_summary_api.execute( + df, _ = self._fin_summary_api.execute( self, code=code, date_yyyymmdd=date_yyyymmdd, ) + return df def get_fin_summary_range( self, @@ -689,9 +698,37 @@ def get_fin_summary_range( .reset_index(drop=True) ) + def get_fin_summary_cursor( + self, + code: str = "", + date_yyyymmdd: str = "", + cursor: str = "", + ) -> tuple[pd.DataFrame, Optional[str]]: + """ + 財務情報サマリ cursor 差分取得対応版 (v2: /fins/summary) + + cursor パラメータを使用した差分取得はプレミアムプラン限定の機能です。 + + Args: + code: 銘柄コード + date_yyyymmdd: 開示日 (YYYYMMDD or YYYY-MM-DD) + cursor: 前回レスポンスで返却された cursor。差分取得に使用します。 + Returns: + tuple[pd.DataFrame, Optional[str]]: 財務情報サマリと cursor のタプル + """ + return self._fin_summary_api.execute( + self, + code=code, + date_yyyymmdd=date_yyyymmdd, + cursor=cursor, + ) + # ------------------------------------------------------------------ # /fins/details (path_old: /fins/fs_details) # ------------------------------------------------------------------ + @deprecated( + "get_fin_details_cursor() is now available for cursor-based incremental retrieval. Consider using it instead." + ) def get_fin_details( self, code: str = "", @@ -706,11 +743,12 @@ def get_fin_details( Returns: pd.DataFrame: 財務諸表詳細 (FS列に各項目が含まれる) """ - return self._fin_details_api.execute( + df, _ = self._fin_details_api.execute( self, code=code, date_yyyymmdd=date_yyyymmdd, ) + return df def get_fin_details_range( self, @@ -770,6 +808,29 @@ def get_fin_details_range( .reset_index(drop=True) ) + def get_fin_details_cursor( + self, + code: str = "", + date_yyyymmdd: str = "", + cursor: str = "", + ) -> tuple[pd.DataFrame, Optional[str]]: + """ + 財務諸表詳細 cursor 差分取得対応版 (v2: /fins/details) + + Args: + code: 銘柄コード + date_yyyymmdd: 開示日 (YYYYMMDD or YYYY-MM-DD) + cursor: 前回レスポンスで返却された cursor。差分取得に使用します。 + Returns: + tuple[pd.DataFrame, Optional[str]]: 財務諸表詳細と cursor のタプル + """ + return self._fin_details_api.execute( + self, + code=code, + date_yyyymmdd=date_yyyymmdd, + cursor=cursor, + ) + # ------------------------------------------------------------------ # /fins/dividend (path_old: /fins/dividend) # ------------------------------------------------------------------ diff --git a/tests/test_client_v2.py b/tests/test_client_v2.py index b88231f..e65dbc4 100644 --- a/tests/test_client_v2.py +++ b/tests/test_client_v2.py @@ -8,7 +8,7 @@ from dateutil import tz import jquantsapi -from jquantsapi import client_v2 +from jquantsapi import client_v2, constants @pytest.mark.parametrize( @@ -613,6 +613,124 @@ def test_get_td_bulk(): assert ret["url"] == "https://example.com/bulk.csv.gz" +FIN_SUMMARY_RECORD = {col: None for col in constants.FIN_SUMMARY_COLUMNS_V2} +FIN_SUMMARY_RECORD.update( + { + "DiscDate": "2025-04-01", + "DiscTime": "08:00", + "Code": "86970", + "DiscNo": "20250401130100", + } +) + +FIN_DETAILS_RECORD = { + "DiscDate": "2025-04-01", + "DiscTime": "08:00", + "Code": "86970", + "DiscNo": "20250401130100", +} + + +def test_get_fin_summary_cursor(): + """get_fin_summary_cursorがtuple(DataFrame, cursor)を返すことを確認""" + ret_value = {"data": [FIN_SUMMARY_RECORD]} + + with patch.object( + jquantsapi.ClientV2, "_load_config", return_value={"api_key": "dummy_key"} + ), patch.object(jquantsapi.ClientV2, "_get") as mock_get: + mock_get.return_value.json.return_value = ret_value + + cli = jquantsapi.ClientV2() + df, cursor = cli.get_fin_summary_cursor(code="86970") + args, _ = mock_get.call_args + assert args[1] == {"code": "86970"} + assert len(df) == 1 + assert cursor is None + + +def test_get_fin_summary_cursor_returns_cursor(): + """get_fin_summary_cursorがレスポンスのcursorを返すことを確認""" + cursor_value = "eyJkIjoiMjAyNS0wNC0wMSJ9" + ret_value = {"data": [FIN_SUMMARY_RECORD], "cursor": cursor_value} + + with patch.object( + jquantsapi.ClientV2, "_load_config", return_value={"api_key": "dummy_key"} + ), patch.object(jquantsapi.ClientV2, "_get") as mock_get: + mock_get.return_value.json.return_value = ret_value + + cli = jquantsapi.ClientV2() + df, cursor = cli.get_fin_summary_cursor(code="86970") + assert len(df) == 1 + assert cursor == cursor_value + + +def test_get_fin_summary_cursor_with_pagination(): + """get_fin_summary_cursorがpagination_keyを自動処理して全件取得することを確認""" + page1 = {"data": [FIN_SUMMARY_RECORD], "pagination_key": "page2key"} + page2 = {"data": [FIN_SUMMARY_RECORD], "cursor": "eyJkIjoiMjAyNS0wNC0wMSJ9"} + + with patch.object( + jquantsapi.ClientV2, "_load_config", return_value={"api_key": "dummy_key"} + ), patch.object(jquantsapi.ClientV2, "_get") as mock_get: + mock_get.return_value.json.side_effect = [page1, page2] + + cli = jquantsapi.ClientV2() + df, cursor = cli.get_fin_summary_cursor(code="86970") + assert len(df) == 2 + assert cursor == "eyJkIjoiMjAyNS0wNC0wMSJ9" + assert mock_get.call_count == 2 + + +def test_get_fin_details_cursor(): + """get_fin_details_cursorがtuple(DataFrame, cursor)を返すことを確認""" + ret_value = {"data": [FIN_DETAILS_RECORD]} + + with patch.object( + jquantsapi.ClientV2, "_load_config", return_value={"api_key": "dummy_key"} + ), patch.object(jquantsapi.ClientV2, "_get") as mock_get: + mock_get.return_value.json.return_value = ret_value + + cli = jquantsapi.ClientV2() + df, cursor = cli.get_fin_details_cursor(code="86970") + args, _ = mock_get.call_args + assert args[1] == {"code": "86970"} + assert len(df) == 1 + assert cursor is None + + +def test_get_fin_details_cursor_returns_cursor(): + """get_fin_details_cursorがレスポンスのcursorを返すことを確認""" + cursor_value = "eyJkIjoiMjAyNS0wNC0wMSJ9" + ret_value = {"data": [FIN_DETAILS_RECORD], "cursor": cursor_value} + + with patch.object( + jquantsapi.ClientV2, "_load_config", return_value={"api_key": "dummy_key"} + ), patch.object(jquantsapi.ClientV2, "_get") as mock_get: + mock_get.return_value.json.return_value = ret_value + + cli = jquantsapi.ClientV2() + df, cursor = cli.get_fin_details_cursor(code="86970") + assert len(df) == 1 + assert cursor == cursor_value + + +def test_get_fin_details_cursor_with_pagination(): + """get_fin_details_cursorがpagination_keyを自動処理して全件取得することを確認""" + page1 = {"data": [FIN_DETAILS_RECORD], "pagination_key": "page2key"} + page2 = {"data": [FIN_DETAILS_RECORD], "cursor": "eyJkIjoiMjAyNS0wNC0wMSJ9"} + + with patch.object( + jquantsapi.ClientV2, "_load_config", return_value={"api_key": "dummy_key"} + ), patch.object(jquantsapi.ClientV2, "_get") as mock_get: + mock_get.return_value.json.side_effect = [page1, page2] + + cli = jquantsapi.ClientV2() + df, cursor = cli.get_fin_details_cursor(code="86970") + assert len(df) == 2 + assert cursor == "eyJkIjoiMjAyNS0wNC0wMSJ9" + assert mock_get.call_count == 2 + + def test_get_raises_with_api_error_message(): """_get()がエラー時にAPIのメッセージを含むHTTPErrorを送出することを確認""" mock_resp = MagicMock() From 5454bb41ac7b72a56e0d451b67f4e6402b928198 Mon Sep 17 00:00:00 2001 From: saikioshima Date: Mon, 8 Jun 2026 19:35:43 +0900 Subject: [PATCH 2/4] =?UTF-8?q?=E3=83=86=E3=82=B9=E3=83=88=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_client_v2.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/test_client_v2.py b/tests/test_client_v2.py index e65dbc4..a0693c8 100644 --- a/tests/test_client_v2.py +++ b/tests/test_client_v2.py @@ -648,6 +648,22 @@ def test_get_fin_summary_cursor(): assert cursor is None +def test_get_fin_summary_cursor_passes_cursor_param(): + """get_fin_summary_cursorがcursor引数をクエリパラメータに渡すことを確認""" + ret_value = {"data": [FIN_SUMMARY_RECORD]} + cursor_value = "eyJkIjoiMjAyNS0wNC0wMSJ9" + + with patch.object( + jquantsapi.ClientV2, "_load_config", return_value={"api_key": "dummy_key"} + ), patch.object(jquantsapi.ClientV2, "_get") as mock_get: + mock_get.return_value.json.return_value = ret_value + + cli = jquantsapi.ClientV2() + cli.get_fin_summary_cursor(cursor=cursor_value) + args, _ = mock_get.call_args + assert args[1] == {"cursor": cursor_value} + + def test_get_fin_summary_cursor_returns_cursor(): """get_fin_summary_cursorがレスポンスのcursorを返すことを確認""" cursor_value = "eyJkIjoiMjAyNS0wNC0wMSJ9" @@ -698,6 +714,22 @@ def test_get_fin_details_cursor(): assert cursor is None +def test_get_fin_details_cursor_passes_cursor_param(): + """get_fin_details_cursorがcursor引数をクエリパラメータに渡すことを確認""" + ret_value = {"data": [FIN_DETAILS_RECORD]} + cursor_value = "eyJkIjoiMjAyNS0wNC0wMSJ9" + + with patch.object( + jquantsapi.ClientV2, "_load_config", return_value={"api_key": "dummy_key"} + ), patch.object(jquantsapi.ClientV2, "_get") as mock_get: + mock_get.return_value.json.return_value = ret_value + + cli = jquantsapi.ClientV2() + cli.get_fin_details_cursor(cursor=cursor_value) + args, _ = mock_get.call_args + assert args[1] == {"cursor": cursor_value} + + def test_get_fin_details_cursor_returns_cursor(): """get_fin_details_cursorがレスポンスのcursorを返すことを確認""" cursor_value = "eyJkIjoiMjAyNS0wNC0wMSJ9" From 2cb129c164a57d36a6b8d7f7f4fef35116091ec2 Mon Sep 17 00:00:00 2001 From: saikioshima Date: Mon, 8 Jun 2026 19:41:31 +0900 Subject: [PATCH 3/4] =?UTF-8?q?devin=E6=8C=87=E6=91=98=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jquantsapi/apis/v2/fins.py | 4 ++++ jquantsapi/client_v2.py | 8 ++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/jquantsapi/apis/v2/fins.py b/jquantsapi/apis/v2/fins.py index cb39d3e..989286c 100644 --- a/jquantsapi/apis/v2/fins.py +++ b/jquantsapi/apis/v2/fins.py @@ -27,6 +27,8 @@ def execute( ) -> tuple[pd.DataFrame, Optional[str]]: """ v2 `/fins/summary` を実行し、財務情報サマリと cursor を返す。 + + cursor は API 仕様上、最終ページのレスポンスにのみ含まれます。 """ url = f"{client.JQUANTS_API_BASE}/fins/summary" # type: ignore[attr-defined] @@ -95,6 +97,8 @@ def execute( ) -> tuple[pd.DataFrame, Optional[str]]: """ v2 `/fins/details` を実行し、財務諸表詳細と cursor を返す。 + + cursor は API 仕様上、最終ページのレスポンスにのみ含まれます。 """ url = f"{client.JQUANTS_API_BASE}/fins/details" # type: ignore[attr-defined] diff --git a/jquantsapi/client_v2.py b/jquantsapi/client_v2.py index 04a5514..5190684 100644 --- a/jquantsapi/client_v2.py +++ b/jquantsapi/client_v2.py @@ -673,12 +673,12 @@ def get_fin_summary_range( buff.append(df) else: future = executor.submit( - self.get_fin_summary, date_yyyymmdd=yyyymmdd + self.get_fin_summary_cursor, date_yyyymmdd=yyyymmdd ) futures[future] = yyyymmdd for future in as_completed(futures): - df = future.result() + df, _ = future.result() if df.empty: continue buff.append(df) @@ -783,12 +783,12 @@ def get_fin_details_range( buff.append(df) else: future = executor.submit( - self.get_fin_details, date_yyyymmdd=yyyymmdd + self.get_fin_details_cursor, date_yyyymmdd=yyyymmdd ) futures[future] = yyyymmdd for future in as_completed(futures): - df = future.result() + df, _ = future.result() if df.empty: continue buff.append(df) From 1a735371031f2d9c6cd3b29385e92d745cba81bf Mon Sep 17 00:00:00 2001 From: saikioshima Date: Tue, 9 Jun 2026 11:57:46 +0900 Subject: [PATCH 4/4] =?UTF-8?q?linter=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- tests/test_client_v2.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_client_v2.py b/tests/test_client_v2.py index a0693c8..8efeaa7 100644 --- a/tests/test_client_v2.py +++ b/tests/test_client_v2.py @@ -1,5 +1,6 @@ from contextlib import nullcontext as does_not_raise from datetime import datetime +from typing import Any from unittest.mock import MagicMock, patch import pandas as pd @@ -613,7 +614,9 @@ def test_get_td_bulk(): assert ret["url"] == "https://example.com/bulk.csv.gz" -FIN_SUMMARY_RECORD = {col: None for col in constants.FIN_SUMMARY_COLUMNS_V2} +FIN_SUMMARY_RECORD: dict[str, Any] = { + col: None for col in constants.FIN_SUMMARY_COLUMNS_V2 +} FIN_SUMMARY_RECORD.update( { "DiscDate": "2025-04-01",