kabukit 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
kabukit-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,33 @@
1
+ Metadata-Version: 2.3
2
+ Name: kabukit
3
+ Version: 0.1.0
4
+ Summary: Add your description here
5
+ Author: Daizu
6
+ Author-email: Daizu <daizutabi@gmail.com>
7
+ Requires-Dist: httpx
8
+ Requires-Dist: platformdirs
9
+ Requires-Dist: polars>=1.32.3
10
+ Requires-Dist: python-dotenv
11
+ Requires-Dist: typer
12
+ Requires-Python: >=3.13
13
+ Description-Content-Type: text/markdown
14
+
15
+ # kabukit
16
+
17
+ A Python toolkit for Japanese financial market data, supporting J-Quants and EDINET APIs.
18
+
19
+ ## Gemini CLI
20
+
21
+ https://github.com/google-gemini/gemini-cli/issues/6297
22
+
23
+ ```bash
24
+ echo $GEMINI_CLI_IDE_SERVER_PORT
25
+ sudo sh -c 'echo "127.0.0.1 host.docker.internal" >> /etc/hosts'
26
+ ```
27
+
28
+ ## References
29
+
30
+ https://jpx.gitbook.io/j-quants-ja/api-reference
31
+ https://japanexchangegroup.github.io/J-Quants-Tutorial/
32
+ https://www.jpx.co.jp/corporate/news/news-releases/0010/20210813-01.html
33
+ https://disclosure2dl.edinet-fsa.go.jp/guide/static/disclosure/WZEK0110.html
@@ -0,0 +1,19 @@
1
+ # kabukit
2
+
3
+ A Python toolkit for Japanese financial market data, supporting J-Quants and EDINET APIs.
4
+
5
+ ## Gemini CLI
6
+
7
+ https://github.com/google-gemini/gemini-cli/issues/6297
8
+
9
+ ```bash
10
+ echo $GEMINI_CLI_IDE_SERVER_PORT
11
+ sudo sh -c 'echo "127.0.0.1 host.docker.internal" >> /etc/hosts'
12
+ ```
13
+
14
+ ## References
15
+
16
+ https://jpx.gitbook.io/j-quants-ja/api-reference
17
+ https://japanexchangegroup.github.io/J-Quants-Tutorial/
18
+ https://www.jpx.co.jp/corporate/news/news-releases/0010/20210813-01.html
19
+ https://disclosure2dl.edinet-fsa.go.jp/guide/static/disclosure/WZEK0110.html
@@ -0,0 +1,77 @@
1
+ [build-system]
2
+ requires = ["uv_build"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "kabukit"
7
+ version = "0.1.0"
8
+ description = "Add your description here"
9
+ readme = "README.md"
10
+ authors = [{ name = "Daizu", email = "daizutabi@gmail.com" }]
11
+ requires-python = ">=3.13"
12
+ dependencies = [
13
+ "httpx",
14
+ "platformdirs",
15
+ "polars>=1.32.3",
16
+ "python-dotenv",
17
+ "typer",
18
+ ]
19
+
20
+ [project.scripts]
21
+ kabu = "kabukit.cli:app"
22
+
23
+ [dependency-groups]
24
+ dev = [
25
+ "matplotlib>=3.6",
26
+ "polars>=1",
27
+ "pytest-clarity>=1",
28
+ "pytest-cov>=6",
29
+ "pytest-httpx",
30
+ "pytest-mock",
31
+ "pytest-randomly>=3.16",
32
+ "pytest-xdist>=3.6",
33
+ ]
34
+ docs = ["mkapi>=4.4", "mkdocs-material"]
35
+
36
+ [tool.pytest.ini_options]
37
+ addopts = ["--cov=kabukit", "--cov-report=lcov:lcov.info", "--doctest-modules"]
38
+
39
+ [tool.coverage.report]
40
+ exclude_lines = ["no cov", "raise NotImplementedError", "if TYPE_CHECKING:"]
41
+ skip_covered = true
42
+
43
+ [tool.ruff]
44
+ line-length = 88
45
+ target-version = "py311"
46
+
47
+ [tool.ruff.lint]
48
+ select = ["ALL"]
49
+ unfixable = ["F401"]
50
+ ignore = [
51
+ "A002",
52
+ "ANN401",
53
+ "D",
54
+ "FBT001",
55
+ "N802",
56
+ "PGH003",
57
+ "PD901",
58
+ "PLR0913",
59
+ "PLC0415",
60
+ "PLR2004",
61
+ "S603",
62
+ "SIM102",
63
+ "TRY003",
64
+ ]
65
+
66
+ [tool.ruff.lint.per-file-ignores]
67
+ "tests/*" = ["ANN", "FBT", "S101"]
68
+
69
+ [tool.basedpyright]
70
+ include = ["src", "tests"]
71
+ reportAny = false
72
+ reportExplicitAny = false
73
+ reportImplicitOverride = false
74
+ reportImportCycles = false
75
+ reportIncompatibleVariableOverride = false
76
+ reportUnusedCallResult = false
77
+ reportUnusedImport = false
@@ -0,0 +1,5 @@
1
+ from .cli import app
2
+
3
+
4
+ def main() -> None:
5
+ app()
@@ -0,0 +1,40 @@
1
+ """kabukit CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import typer
8
+ from httpx import HTTPStatusError
9
+ from typer import Argument, Exit, Option, Typer
10
+
11
+ from .jquants.client import JQuantsClient
12
+
13
+ app = Typer(add_completion=False)
14
+
15
+
16
+ @app.command()
17
+ def auth(
18
+ mailaddress: Annotated[str, Argument(help="J-Quants mail address.")],
19
+ password: Annotated[str, Option(prompt=True, hide_input=True)],
20
+ ) -> None:
21
+ """Authenticate and save/refresh tokens."""
22
+ client = JQuantsClient()
23
+
24
+ try:
25
+ client.auth(mailaddress, password)
26
+ except HTTPStatusError as e:
27
+ typer.echo(f"Authentication failed: {e}")
28
+ raise Exit(1) from None
29
+
30
+ client = JQuantsClient()
31
+ typer.echo(f"refreshToken: {client.refresh_token[:30]}...")
32
+ typer.echo(f"idToken: {client.id_token[:30]}...")
33
+
34
+
35
+ @app.command()
36
+ def version() -> None:
37
+ """Show kabukit version."""
38
+ from importlib.metadata import version
39
+
40
+ typer.echo(f"kabukit version: {version('kabukit')}")
File without changes
@@ -0,0 +1,324 @@
1
+ """This module provides a client for the J-Quants API.
2
+
3
+ It handles authentication and provides methods to interact with
4
+ the API endpoints.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import datetime
10
+ import os
11
+ from enum import StrEnum
12
+ from functools import cached_property
13
+ from pathlib import Path
14
+ from typing import TYPE_CHECKING
15
+
16
+ import polars as pl
17
+ from dotenv import load_dotenv, set_key
18
+ from httpx import Client
19
+ from platformdirs import user_config_dir
20
+ from polars import DataFrame
21
+
22
+ if TYPE_CHECKING:
23
+ from collections.abc import Iterator
24
+ from typing import Any
25
+
26
+ from httpx import HTTPStatusError # noqa: F401
27
+ from httpx._types import QueryParamTypes
28
+
29
+ API_VERSION = "v1"
30
+
31
+
32
+ class AuthenticationError(Exception):
33
+ """Custom exception for authentication failures."""
34
+
35
+
36
+ class AuthKey(StrEnum):
37
+ """Environment variable keys for J-Quants authentication."""
38
+
39
+ REFRESH_TOKEN = "JQUANTS_REFRESH_TOKEN" # noqa: S105
40
+ ID_TOKEN = "JQUANTS_ID_TOKEN" # noqa: S105
41
+
42
+
43
+ class JQuantsClient:
44
+ """A client for interacting with the J-Quants API.
45
+
46
+ This client manages API authentication tokens (refresh and ID)
47
+ and provides methods to access various J-Quants API
48
+ endpoints. Tokens are loaded from and saved to a file in the
49
+ user's standard config directory.
50
+
51
+ Attributes:
52
+ client: An httpx.Client instance for making API requests.
53
+ refresh_token: The refresh token for authentication.
54
+ id_token: The ID token for API requests.
55
+ """
56
+
57
+ client: Client
58
+ refresh_token: str | None
59
+ id_token: str | None
60
+
61
+ def __init__(self) -> None:
62
+ """Initializes the JQuantsClient.
63
+
64
+ It sets up the httpx client, determines the config path,
65
+ loads authentication tokens, and sets the auth header if an
66
+ ID token is present.
67
+ """
68
+ self.client = Client(base_url=f"https://api.jquants.com/{API_VERSION}")
69
+ self._setup_config_path()
70
+ self._load_tokens()
71
+ self.set_header()
72
+
73
+ @cached_property
74
+ def dotenv_path(self) -> Path:
75
+ """Returns the path to the .env file in the user config directory."""
76
+ config_dir = Path(user_config_dir("kabukit"))
77
+ config_dir.mkdir(parents=True, exist_ok=True)
78
+ return config_dir / ".env"
79
+
80
+ def _setup_config_path(self) -> None:
81
+ """Determines the config path and creates the directory."""
82
+ # Accessing dotenv_path property will create the directory if it doesn't exist
83
+ _ = self.dotenv_path
84
+
85
+ def _load_tokens(self) -> None:
86
+ """Loads tokens from the .env file."""
87
+ load_dotenv(self.dotenv_path)
88
+ self.refresh_token = os.environ.get(AuthKey.REFRESH_TOKEN)
89
+ self.id_token = os.environ.get(AuthKey.ID_TOKEN)
90
+
91
+ def set_header(self) -> None:
92
+ """Sets the Authorization header if an ID token is available."""
93
+ if self.id_token:
94
+ self.client.headers["Authorization"] = f"Bearer {self.id_token}"
95
+ # Clear header if no ID token is available
96
+ elif "Authorization" in self.client.headers:
97
+ del self.client.headers["Authorization"]
98
+
99
+ def auth(self, mailaddress: str, password: str) -> None:
100
+ """Authenticates, saves tokens, and sets the auth header.
101
+
102
+ Args:
103
+ mailaddress: The user's email address.
104
+ password: The user's password.
105
+
106
+ Raises:
107
+ HTTPStatusError: If any API request fails.
108
+ """
109
+ self.refresh_token = self.get_refresh_token(mailaddress, password)
110
+ self.id_token = self.get_id_token(self.refresh_token)
111
+ set_key(self.dotenv_path, AuthKey.REFRESH_TOKEN, self.refresh_token)
112
+ set_key(self.dotenv_path, AuthKey.ID_TOKEN, self.id_token)
113
+ self.set_header()
114
+
115
+ def post(self, url: str, json: Any | None = None) -> Any:
116
+ """Sends a POST request to the specified URL.
117
+
118
+ Args:
119
+ url: The URL path for the POST request.
120
+ json: The JSON payload for the request body.
121
+
122
+ Returns:
123
+ The JSON response from the API.
124
+
125
+ Raises:
126
+ AuthenticationError: If no ID token is available.
127
+ HTTPStatusError: If the API request fails.
128
+ """
129
+ if not self.id_token:
130
+ msg = "ID token is not available. Please authenticate first."
131
+ raise AuthenticationError(msg)
132
+
133
+ resp = self.client.post(url, json=json)
134
+ resp.raise_for_status()
135
+ return resp.json()
136
+
137
+ def get_refresh_token(self, mailaddress: str, password: str) -> str:
138
+ """Gets a new refresh token from the API.
139
+
140
+ Args:
141
+ mailaddress: The user's email address.
142
+ password: The user's password.
143
+
144
+ Returns:
145
+ The new refresh token.
146
+
147
+ Raises:
148
+ httpx.HTTPStatusError: If the API request fails.
149
+ """
150
+ json_data = {"mailaddress": mailaddress, "password": password}
151
+ return self.post("/token/auth_user", json=json_data)["refreshToken"]
152
+
153
+ def get_id_token(self, refresh_token: str) -> str:
154
+ """Gets a new ID token from the API.
155
+
156
+ Args:
157
+ refresh_token: The refresh token to use.
158
+
159
+ Returns:
160
+ The new ID token.
161
+
162
+ Raises:
163
+ HTTPStatusError: If the API request fails.
164
+ """
165
+ url = f"/token/auth_refresh?refreshtoken={refresh_token}"
166
+ return self.post(url)["idToken"]
167
+
168
+ def get(self, url: str, params: QueryParamTypes | None = None) -> Any:
169
+ """Sends a GET request to the specified URL.
170
+
171
+ Args:
172
+ url: The URL path for the GET request.
173
+ params: The query parameters for the request.
174
+
175
+ Returns:
176
+ The JSON response from the API.
177
+
178
+ Raises:
179
+ AuthenticationError: If no ID token is available.
180
+ HTTPStatusError: If the API request fails.
181
+ """
182
+ if not self.id_token:
183
+ msg = "ID token is not available. Please authenticate first."
184
+ raise AuthenticationError(msg)
185
+
186
+ resp = self.client.get(url, params=params)
187
+ resp.raise_for_status()
188
+ return resp.json()
189
+
190
+ def get_listed_info(
191
+ self,
192
+ code: str | None = None,
193
+ date: str | datetime.date | None = None,
194
+ ) -> DataFrame:
195
+ """Gets listed info (e.g., stock details) from the API.
196
+
197
+ Args:
198
+ code: Optional. The stock code to filter by.
199
+ date: Optional. The date to filter by (YYYY-MM-DD format
200
+ or datetime.date object).
201
+
202
+ Returns:
203
+ A Polars DataFrame containing the listed info.
204
+
205
+ Raises:
206
+ AuthenticationError: If no ID token is available.
207
+ HTTPStatusError: If the API request fails.
208
+ """
209
+ params = params_code_date(code, date)
210
+ url = "/listed/info"
211
+ data = self.get(url, params)
212
+ df = DataFrame(data["info"])
213
+ return df.with_columns(pl.col("Date").str.to_date())
214
+
215
+ def iter_pagaes(
216
+ self,
217
+ url: str,
218
+ params: dict[str, Any] | None,
219
+ name: str,
220
+ ) -> Iterator[DataFrame]:
221
+ """Iterates through paginated API responses.
222
+
223
+ Args:
224
+ url: The base URL for the API endpoint.
225
+ params: Optional. Dictionary of query parameters.
226
+ name: The key in the JSON response containing the list of items.
227
+
228
+ Yields:
229
+ A Polars DataFrame for each page of data.
230
+
231
+ Raises:
232
+ AuthenticationError: If no ID token is available.
233
+ HTTPStatusError: If the API request fails.
234
+ """
235
+ params = params or {}
236
+
237
+ while True:
238
+ data = self.get(url, params)
239
+ yield DataFrame(data[name])
240
+ if "pagination_key" in data:
241
+ params["pagination_key"] = data["pagination_key"]
242
+ else:
243
+ break
244
+
245
+ def get_prices(
246
+ self,
247
+ code: str | None = None,
248
+ date: str | datetime.date | None = None,
249
+ from_: str | datetime.date | None = None,
250
+ to: str | datetime.date | None = None,
251
+ ) -> DataFrame:
252
+ """Gets daily stock prices from the API.
253
+
254
+ Args:
255
+ code: Optional. The stock code to filter by.
256
+ date: Optional. The specific date for which to retrieve prices.
257
+ Cannot be used with `from_` or `to`.
258
+ from_: Optional. The start date for a price range.
259
+ Requires `to` if `date` is not specified.
260
+ to: Optional. The end date for a price range.
261
+ Requires `from_` if `date` is not specified.
262
+
263
+ Returns:
264
+ A Polars DataFrame containing daily stock prices.
265
+
266
+ Raises:
267
+ ValueError: If both `date` and `from_`/`to` are specified.
268
+ AuthenticationError: If no ID token is available.
269
+ HTTPStatusError: If the API request fails.
270
+ """
271
+ params = params_code_date(code, date)
272
+
273
+ if date and (from_ or to):
274
+ msg = "Cannot specify both date and from/to parameters."
275
+ raise ValueError(msg)
276
+
277
+ if not date and from_:
278
+ params["from"] = date_to_str(from_)
279
+ if not date and to:
280
+ params["to"] = date_to_str(to)
281
+
282
+ url = "/prices/daily_quotes"
283
+ name = "daily_quotes"
284
+
285
+ df = pl.concat(self.iter_pagaes(url, params, name))
286
+ if df.is_empty():
287
+ return df
288
+
289
+ return df.with_columns(pl.col("Date").str.to_date())
290
+
291
+
292
+ def params_code_date(
293
+ code: str | None,
294
+ date: str | datetime.date | None,
295
+ ) -> dict[str, str]:
296
+ """Constructs a dictionary of parameters for code and date filtering.
297
+
298
+ Args:
299
+ code: Optional. The stock code.
300
+ date: Optional. The date (string or datetime.date object).
301
+
302
+ Returns:
303
+ A dictionary containing 'code' and/or 'date' parameters.
304
+ """
305
+ params: dict[str, str] = {}
306
+ if code:
307
+ params["code"] = code
308
+ if date:
309
+ params["date"] = date_to_str(date)
310
+ return params
311
+
312
+
313
+ def date_to_str(date: str | datetime.date) -> str:
314
+ """Converts a date object or string to a YYYY-MM-DD string.
315
+
316
+ Args:
317
+ date: The date to convert (string or datetime.date object).
318
+
319
+ Returns:
320
+ The date as a YYYY-MM-DD string.
321
+ """
322
+ if isinstance(date, datetime.date):
323
+ return date.strftime("%Y-%m-%d")
324
+ return date