diff --git a/jquantsapi/apis/v2/fins.py b/jquantsapi/apis/v2/fins.py index efa9559..989286c 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,42 @@ 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 を返す。 + + cursor は API 仕様上、最終ページのレスポンスにのみ含まれます。 """ + 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 +75,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 +92,41 @@ 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 を返す。 + + cursor は API 仕様上、最終ページのレスポンスにのみ含まれます。 """ + 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 +134,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..5190684 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, @@ -664,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) @@ -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, @@ -745,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) @@ -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..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 @@ -8,7 +9,7 @@ from dateutil import tz import jquantsapi -from jquantsapi import client_v2 +from jquantsapi import client_v2, constants @pytest.mark.parametrize( @@ -613,6 +614,158 @@ def test_get_td_bulk(): assert ret["url"] == "https://example.com/bulk.csv.gz" +FIN_SUMMARY_RECORD: dict[str, Any] = { + 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_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" + 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_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" + 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()