krx-openapi 0.1.0__py3-none-any.whl
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.
- krx_openapi/__init__.py +55 -0
- krx_openapi/__main__.py +8 -0
- krx_openapi/_config.py +74 -0
- krx_openapi/_endpoint.py +98 -0
- krx_openapi/catalog.py +198 -0
- krx_openapi/cli.py +170 -0
- krx_openapi/client.py +238 -0
- krx_openapi/errors.py +86 -0
- krx_openapi/py.typed +0 -0
- krx_openapi/session.py +160 -0
- krx_openapi/types.py +28 -0
- krx_openapi-0.1.0.dist-info/METADATA +236 -0
- krx_openapi-0.1.0.dist-info/RECORD +16 -0
- krx_openapi-0.1.0.dist-info/WHEEL +4 -0
- krx_openapi-0.1.0.dist-info/entry_points.txt +2 -0
- krx_openapi-0.1.0.dist-info/licenses/LICENSE +21 -0
krx_openapi/client.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""KRX -- the entry point.
|
|
2
|
+
|
|
3
|
+
Built from an API key (constructor, ``KRX_API_KEY`` env, or config file), it holds a
|
|
4
|
+
:class:`KRXSession` and wires the seven category sub-surfaces -- ``index``,
|
|
5
|
+
``stock``, ``etp``, ``bond``, ``derivatives``, ``commodity``, ``esg`` -- whose methods
|
|
6
|
+
mirror the KRX service list one-to-one. Each method takes ``date`` (the
|
|
7
|
+
``YYYYMMDD`` trade date) and returns ``list[dict[str, str]]``, the vendor's rows with
|
|
8
|
+
their own field names.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import inspect
|
|
14
|
+
|
|
15
|
+
from ._endpoint import ENDPOINTS
|
|
16
|
+
from .session import KRXSession
|
|
17
|
+
from .types import Market, Row, StockDerivMarket
|
|
18
|
+
|
|
19
|
+
_STOCK_TRD = {"KOSPI": "stk_bydd_trd", "KOSDAQ": "ksq_bydd_trd", "KONEX": "knx_bydd_trd"}
|
|
20
|
+
_STOCK_INFO = {
|
|
21
|
+
"KOSPI": "stk_isu_base_info", "KOSDAQ": "ksq_isu_base_info", "KONEX": "knx_isu_base_info",
|
|
22
|
+
}
|
|
23
|
+
_STOCK_FUTURES = {"KOSPI": "eqsfu_stk_bydd_trd", "KOSDAQ": "eqkfu_ksq_bydd_trd"}
|
|
24
|
+
_STOCK_OPTIONS = {"KOSPI": "eqsop_bydd_trd", "KOSDAQ": "eqkop_bydd_trd"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _pick(mapping: dict[str, str], market: str) -> str:
|
|
28
|
+
"""Resolve a ``market`` against ``mapping``, case-insensitively, with a clear
|
|
29
|
+
``ValueError`` for an unknown code (a caller mistake, not a runtime failure).
|
|
30
|
+
|
|
31
|
+
``market`` is upper-cased first, so ``"kospi"`` and ``"KOSPI"`` both resolve;
|
|
32
|
+
the type hint keeps the canonical upper-case form (:data:`Market`)."""
|
|
33
|
+
try:
|
|
34
|
+
return mapping[market.upper()]
|
|
35
|
+
except (KeyError, AttributeError):
|
|
36
|
+
raise ValueError(f"market must be one of {list(mapping)}, got {market!r}") from None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _Surface:
|
|
40
|
+
"""Base for a category sub-surface: holds the session and fetches by api_id."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, session: KRXSession) -> None:
|
|
43
|
+
self._session = session
|
|
44
|
+
|
|
45
|
+
def _fetch(self, api_id: str, **params: str) -> list[Row]:
|
|
46
|
+
return self._session.fetch(ENDPOINTS[api_id], **params)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class _Index(_Surface):
|
|
50
|
+
"""지수 (idx) -- daily index series."""
|
|
51
|
+
|
|
52
|
+
def krx(self, date: str) -> list[Row]:
|
|
53
|
+
"""KRX 시리즈 일별시세정보 (idx/krx_dd_trd)."""
|
|
54
|
+
return self._fetch("krx_dd_trd", basDd=date)
|
|
55
|
+
|
|
56
|
+
def kospi(self, date: str) -> list[Row]:
|
|
57
|
+
"""KOSPI 시리즈 일별시세정보 (idx/kospi_dd_trd)."""
|
|
58
|
+
return self._fetch("kospi_dd_trd", basDd=date)
|
|
59
|
+
|
|
60
|
+
def kosdaq(self, date: str) -> list[Row]:
|
|
61
|
+
"""KOSDAQ 시리즈 일별시세정보 (idx/kosdaq_dd_trd)."""
|
|
62
|
+
return self._fetch("kosdaq_dd_trd", basDd=date)
|
|
63
|
+
|
|
64
|
+
def bond(self, date: str) -> list[Row]:
|
|
65
|
+
"""채권지수 시세정보 (idx/bon_dd_trd)."""
|
|
66
|
+
return self._fetch("bon_dd_trd", basDd=date)
|
|
67
|
+
|
|
68
|
+
def derivatives(self, date: str) -> list[Row]:
|
|
69
|
+
"""파생상품지수 시세정보 (idx/drvprod_dd_trd)."""
|
|
70
|
+
return self._fetch("drvprod_dd_trd", basDd=date)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class _Stock(_Surface):
|
|
74
|
+
"""주식 (sto) -- daily trades and the issue master."""
|
|
75
|
+
|
|
76
|
+
def info(self, date: str, market: Market = "KOSPI") -> list[Row]:
|
|
77
|
+
"""종목기본정보 (sto/{stk|ksq|knx}_isu_base_info). The issue master:
|
|
78
|
+
ISU_CD/ISU_SRT_CD/ISU_NM, LIST_DD, MKT_TP_NM, SECUGRP_NM, SECT_TP_NM, PARVAL,
|
|
79
|
+
LIST_SHRS. market KOSPI/KOSDAQ/KONEX."""
|
|
80
|
+
return self._fetch(_pick(_STOCK_INFO, market), basDd=date)
|
|
81
|
+
|
|
82
|
+
def daily(self, date: str, market: Market = "KOSPI") -> list[Row]:
|
|
83
|
+
"""일별매매정보 (sto/{stk|ksq|knx}_bydd_trd). market: KOSPI 유가증권,
|
|
84
|
+
KOSDAQ 코스닥, KONEX 코넥스."""
|
|
85
|
+
return self._fetch(_pick(_STOCK_TRD, market), basDd=date)
|
|
86
|
+
|
|
87
|
+
def warrant(self, date: str) -> list[Row]:
|
|
88
|
+
"""신주인수권증권 일별매매정보 (sto/sw_bydd_trd)."""
|
|
89
|
+
return self._fetch("sw_bydd_trd", basDd=date)
|
|
90
|
+
|
|
91
|
+
def right(self, date: str) -> list[Row]:
|
|
92
|
+
"""신주인수권증서 일별매매정보 (sto/sr_bydd_trd)."""
|
|
93
|
+
return self._fetch("sr_bydd_trd", basDd=date)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class _Etp(_Surface):
|
|
97
|
+
"""증권상품 (etp) -- ETF / ETN / ELW daily trades."""
|
|
98
|
+
|
|
99
|
+
def etf(self, date: str) -> list[Row]:
|
|
100
|
+
"""ETF 일별매매정보 (etp/etf_bydd_trd). Carries each ETF's base-index name
|
|
101
|
+
(IDX_IND_NM) and NAV alongside price."""
|
|
102
|
+
return self._fetch("etf_bydd_trd", basDd=date)
|
|
103
|
+
|
|
104
|
+
def etn(self, date: str) -> list[Row]:
|
|
105
|
+
"""ETN 일별매매정보 (etp/etn_bydd_trd)."""
|
|
106
|
+
return self._fetch("etn_bydd_trd", basDd=date)
|
|
107
|
+
|
|
108
|
+
def elw(self, date: str) -> list[Row]:
|
|
109
|
+
"""ELW 일별매매정보 (etp/elw_bydd_trd)."""
|
|
110
|
+
return self._fetch("elw_bydd_trd", basDd=date)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class _Bond(_Surface):
|
|
114
|
+
"""채권 (bon) -- daily bond trades (price + yield pairs)."""
|
|
115
|
+
|
|
116
|
+
def treasury(self, date: str) -> list[Row]:
|
|
117
|
+
"""국채전문유통시장 일별매매정보 (bon/kts_bydd_trd)."""
|
|
118
|
+
return self._fetch("kts_bydd_trd", basDd=date)
|
|
119
|
+
|
|
120
|
+
def general(self, date: str) -> list[Row]:
|
|
121
|
+
"""일반채권시장 일별매매정보 (bon/bnd_bydd_trd)."""
|
|
122
|
+
return self._fetch("bnd_bydd_trd", basDd=date)
|
|
123
|
+
|
|
124
|
+
def small_lot(self, date: str) -> list[Row]:
|
|
125
|
+
"""소액채권시장 일별매매정보 (bon/smb_bydd_trd)."""
|
|
126
|
+
return self._fetch("smb_bydd_trd", basDd=date)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class _Derivatives(_Surface):
|
|
130
|
+
"""파생상품 (drv) -- futures and options daily trades."""
|
|
131
|
+
|
|
132
|
+
def futures(self, date: str) -> list[Row]:
|
|
133
|
+
"""선물 일별매매정보, 주식선물 제외 (drv/fut_bydd_trd)."""
|
|
134
|
+
return self._fetch("fut_bydd_trd", basDd=date)
|
|
135
|
+
|
|
136
|
+
def options(self, date: str) -> list[Row]:
|
|
137
|
+
"""옵션 일별매매정보, 주식옵션 제외 (drv/opt_bydd_trd). Large response (every
|
|
138
|
+
listed series) -- can take tens of seconds."""
|
|
139
|
+
return self._fetch("opt_bydd_trd", basDd=date)
|
|
140
|
+
|
|
141
|
+
def stock_futures(self, date: str, market: StockDerivMarket = "KOSPI") -> list[Row]:
|
|
142
|
+
"""주식선물 일별매매정보 (drv/eqsfu_stk_bydd_trd 유가, eqkfu_ksq_bydd_trd
|
|
143
|
+
코스닥). market KOSPI/KOSDAQ (KONEX has no stock futures)."""
|
|
144
|
+
return self._fetch(_pick(_STOCK_FUTURES, market), basDd=date)
|
|
145
|
+
|
|
146
|
+
def stock_options(self, date: str, market: StockDerivMarket = "KOSPI") -> list[Row]:
|
|
147
|
+
"""주식옵션 일별매매정보 (drv/eqsop_bydd_trd 유가, eqkop_bydd_trd 코스닥).
|
|
148
|
+
market KOSPI/KOSDAQ (KONEX has no stock options)."""
|
|
149
|
+
return self._fetch(_pick(_STOCK_OPTIONS, market), basDd=date)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class _Commodity(_Surface):
|
|
153
|
+
"""일반상품 (gen) -- oil, gold, emissions daily trades."""
|
|
154
|
+
|
|
155
|
+
def oil(self, date: str) -> list[Row]:
|
|
156
|
+
"""석유시장 일별매매정보 (gen/oil_bydd_trd)."""
|
|
157
|
+
return self._fetch("oil_bydd_trd", basDd=date)
|
|
158
|
+
|
|
159
|
+
def gold(self, date: str) -> list[Row]:
|
|
160
|
+
"""금시장 일별매매정보 (gen/gold_bydd_trd)."""
|
|
161
|
+
return self._fetch("gold_bydd_trd", basDd=date)
|
|
162
|
+
|
|
163
|
+
def emissions(self, date: str) -> list[Row]:
|
|
164
|
+
"""배출권 시장 일별매매정보 (gen/ets_bydd_trd)."""
|
|
165
|
+
return self._fetch("ets_bydd_trd", basDd=date)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class _Esg(_Surface):
|
|
169
|
+
"""ESG (esg)."""
|
|
170
|
+
|
|
171
|
+
def sri_bond(self, date: str) -> list[Row]:
|
|
172
|
+
"""사회책임투자채권 정보 (esg/sri_bond_info)."""
|
|
173
|
+
return self._fetch("sri_bond_info", basDd=date)
|
|
174
|
+
|
|
175
|
+
def index(self, date: str) -> list[Row]:
|
|
176
|
+
"""ESG 지수 (esg/esg_index_info)."""
|
|
177
|
+
return self._fetch("esg_index_info", basDd=date)
|
|
178
|
+
|
|
179
|
+
def etp(self, date: str) -> list[Row]:
|
|
180
|
+
"""ESG 증권상품 (esg/esg_etp_info)."""
|
|
181
|
+
return self._fetch("esg_etp_info", basDd=date)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# The category sub-surfaces by accessor name. One source of truth: KRX builds one of
|
|
185
|
+
# each here, and _accepts_market introspects the same classes -- so the CLI can answer
|
|
186
|
+
# "does this method take --market?" offline (no key) without duplicating the fact.
|
|
187
|
+
_SURFACES: dict[str, type[_Surface]] = {
|
|
188
|
+
"index": _Index, # 지수 (idx)
|
|
189
|
+
"stock": _Stock, # 주식 (sto)
|
|
190
|
+
"etp": _Etp, # 증권상품 (etp)
|
|
191
|
+
"bond": _Bond, # 채권 (bon)
|
|
192
|
+
"derivatives": _Derivatives, # 파생상품 (drv)
|
|
193
|
+
"commodity": _Commodity, # 일반상품 (gen)
|
|
194
|
+
"esg": _Esg, # ESG (esg)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _accepts_market(group: str, method: str) -> bool:
|
|
199
|
+
"""Whether an accessor ``group.method`` takes a ``market`` argument.
|
|
200
|
+
|
|
201
|
+
Read from the accessor method's own signature, so it cannot drift from the
|
|
202
|
+
method definition. Lets the CLI reject a stray ``--market`` as a usage error
|
|
203
|
+
before it constructs a client (which would need an API key)."""
|
|
204
|
+
func = getattr(_SURFACES[group], method)
|
|
205
|
+
return "market" in inspect.signature(func).parameters
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class KRX:
|
|
209
|
+
"""Client for the KRX Open API. Groups services as sub-surfaces.
|
|
210
|
+
|
|
211
|
+
Construct it with an API key, or leave it out to resolve one from the
|
|
212
|
+
``KRX_API_KEY`` environment variable or ``~/.config/krx-openapi/credentials.json``::
|
|
213
|
+
|
|
214
|
+
krx = KRX()
|
|
215
|
+
rows = krx.index.kospi("20200414")
|
|
216
|
+
stocks = krx.stock.daily("20200414", market="KOSPI")
|
|
217
|
+
|
|
218
|
+
Each service is authorized separately on the KRX account; a call to one not
|
|
219
|
+
applied for raises :class:`KRXAuthError` (respCode 401). Rows come back as
|
|
220
|
+
``list[dict[str, str]]`` -- frame them with ``pandas.DataFrame(rows)`` /
|
|
221
|
+
``polars.DataFrame(rows)``.
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
index: _Index
|
|
225
|
+
stock: _Stock
|
|
226
|
+
etp: _Etp
|
|
227
|
+
bond: _Bond
|
|
228
|
+
derivatives: _Derivatives
|
|
229
|
+
commodity: _Commodity
|
|
230
|
+
esg: _Esg
|
|
231
|
+
|
|
232
|
+
def __init__(self, api_key: str | None = None, *, timeout: float = 60.0) -> None:
|
|
233
|
+
self._session = KRXSession(api_key, timeout=timeout)
|
|
234
|
+
for name, surface_cls in _SURFACES.items():
|
|
235
|
+
setattr(self, name, surface_cls(self._session))
|
|
236
|
+
|
|
237
|
+
def __repr__(self) -> str:
|
|
238
|
+
return f"KRX({self._session!r})"
|
krx_openapi/errors.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Exception hierarchy for krx-openapi.
|
|
2
|
+
|
|
3
|
+
Every *operational* error this package raises derives from :class:`KRXError`, so one
|
|
4
|
+
``except KRXError`` catches them all. The subclasses separate the failure modes a
|
|
5
|
+
caller handles differently: a misconfiguration caught before any request
|
|
6
|
+
(:class:`KRXConfigError`), a rejected or un-applied service key
|
|
7
|
+
(:class:`KRXAuthError`), a daily-quota exhaustion (:class:`KRXRateLimitError`), any
|
|
8
|
+
other vendor-reported error inside a well-formed response
|
|
9
|
+
(:class:`KRXResponseError`), and a transport failure that never produced a KRX body
|
|
10
|
+
(:class:`KRXNetworkError`). Invalid *caller input* -- an unknown ``market`` code raises
|
|
11
|
+
``ValueError``, an unknown accessor ``group``/``method`` raises ``KeyError`` -- the
|
|
12
|
+
usual signal for a caller mistake rather than a runtime failure.
|
|
13
|
+
|
|
14
|
+
KRX answers every request with HTTP 200 and marks failure with a non-empty
|
|
15
|
+
``respCode`` in the JSON body; :func:`_error_for` turns that code into the right
|
|
16
|
+
subclass.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class KRXError(RuntimeError):
|
|
23
|
+
"""Base class for every operational error raised by krx-openapi."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class KRXConfigError(KRXError):
|
|
27
|
+
"""The client is misconfigured; raised before any request goes out.
|
|
28
|
+
|
|
29
|
+
The usual cause is a missing API key -- neither passed to ``KRX(...)`` nor
|
|
30
|
+
present in the ``KRX_API_KEY`` environment variable nor the config file.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class KRXNetworkError(KRXError):
|
|
35
|
+
"""The request failed at the transport or HTTP layer.
|
|
36
|
+
|
|
37
|
+
A timeout, DNS failure, connection reset, an interrupted read, a non-success HTTP
|
|
38
|
+
status, or a 200 whose body is not JSON (a proxy/maintenance page). The underlying
|
|
39
|
+
exception is chained as ``__cause__``.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class KRXRateLimitError(KRXError):
|
|
44
|
+
"""The daily call quota was exceeded (HTTP 429).
|
|
45
|
+
|
|
46
|
+
KRX limits each *auth key* (not IP) to 10,000 calls per day, reset at midnight.
|
|
47
|
+
It is its own class -- not a :class:`KRXNetworkError` -- so a bulk caller can
|
|
48
|
+
catch it distinctly and stop until the quota resets rather than retry.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class KRXResponseError(KRXError):
|
|
53
|
+
"""KRX returned a well-formed response carrying an error ``respCode``.
|
|
54
|
+
|
|
55
|
+
``code`` and ``message`` are the vendor's own (``respCode`` / ``respMsg``), so a
|
|
56
|
+
caller can branch on the code without parsing the message text.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(self, code: str, message: str) -> None:
|
|
60
|
+
self.code = code
|
|
61
|
+
self.message = message
|
|
62
|
+
super().__init__(f"KRX {code}: {message}")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class KRXAuthError(KRXResponseError):
|
|
66
|
+
"""KRX rejected the key, or the service was not applied for (respCode 401).
|
|
67
|
+
|
|
68
|
+
Each KRX service (지수, 주식, ...) is authorized separately on the account; a
|
|
69
|
+
request for an un-applied service returns 401 exactly as a bad key does. It
|
|
70
|
+
subclasses :class:`KRXResponseError`, so ``except KRXResponseError`` still
|
|
71
|
+
catches it while a caller can catch an auth failure distinctly.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(self, message: str) -> None:
|
|
75
|
+
super().__init__("401", message)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _error_for(code: str, message: str) -> KRXResponseError:
|
|
79
|
+
"""Build the most specific :class:`KRXResponseError` for a KRX ``respCode``.
|
|
80
|
+
|
|
81
|
+
A rate limit (429) arrives as an HTTP status, not a ``respCode`` -- handled in
|
|
82
|
+
the session -- so it is not mapped here; this only covers the in-body codes.
|
|
83
|
+
"""
|
|
84
|
+
if code == "401":
|
|
85
|
+
return KRXAuthError(message or "KRX rejected the key or the service is not applied for")
|
|
86
|
+
return KRXResponseError(code, message)
|
krx_openapi/py.typed
ADDED
|
File without changes
|
krx_openapi/session.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""KRXSession -- key injection, one GET over the standard library, the status contract.
|
|
2
|
+
|
|
3
|
+
The session holds the API key and turns an endpoint + params into rows: a
|
|
4
|
+
``list[dict[str, str]]``, the vendor's ``OutBlock_*`` array passed through with its
|
|
5
|
+
own field names. No third-party HTTP client -- ``urllib`` carries it, so the package
|
|
6
|
+
has zero runtime dependencies.
|
|
7
|
+
|
|
8
|
+
KRX answers with HTTP 200 even on failure and marks the failure with a non-empty
|
|
9
|
+
``respCode`` (+ ``respMsg``) in the JSON body; :meth:`fetch` applies that contract,
|
|
10
|
+
raising the typed error and otherwise returning the single array the body carries.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import http.client
|
|
16
|
+
import json
|
|
17
|
+
import urllib.error
|
|
18
|
+
import urllib.parse
|
|
19
|
+
import urllib.request
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from ._config import resolve_api_key
|
|
23
|
+
from ._endpoint import SAMPLE_DATE, SAMPLE_KEY, KRXEndpoint
|
|
24
|
+
from .errors import KRXNetworkError, KRXRateLimitError, _error_for
|
|
25
|
+
from .types import Row
|
|
26
|
+
|
|
27
|
+
_AUTH_HEADER = "AUTH_KEY"
|
|
28
|
+
_USER_AGENT = "krx-openapi"
|
|
29
|
+
|
|
30
|
+
# KRX limits each auth key to 10,000 calls/day; over it, the request returns 429.
|
|
31
|
+
_RATE_LIMIT_STATUS = 429
|
|
32
|
+
# A wrong key/URL, the sample path with a real key, or http:// all return 403.
|
|
33
|
+
_FORBIDDEN_STATUS = 403
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
37
|
+
"""Refuse every redirect instead of following it.
|
|
38
|
+
|
|
39
|
+
``urllib`` follows a 3xx automatically and copies the request headers -- the
|
|
40
|
+
``AUTH_KEY`` among them -- onto the new location, so a redirect (in particular an
|
|
41
|
+
https -> http downgrade) would hand the API key to another host, in cleartext on
|
|
42
|
+
the downgrade. Refusing turns any redirect into an ``HTTPError``, which
|
|
43
|
+
:meth:`KRXSession._get` already surfaces as :class:`KRXNetworkError`; the key
|
|
44
|
+
never leaves the original https host.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
|
|
48
|
+
# The redirect target (newurl) is server-controlled -- keep it out of the error
|
|
49
|
+
# so nothing it carries can surface through the chained exception.
|
|
50
|
+
raise urllib.error.HTTPError(req.full_url, code, "redirect refused", headers, fp)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# One opener for the whole package: the default global opener follows redirects, so
|
|
54
|
+
# a private opener with the no-redirect handler is what actually closes the leak.
|
|
55
|
+
_OPENER = urllib.request.build_opener(_NoRedirect)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class KRXSession:
|
|
59
|
+
"""Holds the API key and fetches endpoints as raw rows.
|
|
60
|
+
|
|
61
|
+
The key comes from ``api_key``, then ``$KRX_API_KEY``, then the config file
|
|
62
|
+
``~/.config/krx-openapi/credentials.json``; construction raises
|
|
63
|
+
:class:`KRXConfigError` when none of them supplies one.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self, api_key: str | None = None, *, timeout: float = 60.0) -> None:
|
|
67
|
+
self.api_key = resolve_api_key(api_key)
|
|
68
|
+
self.timeout = timeout
|
|
69
|
+
|
|
70
|
+
def __repr__(self) -> str:
|
|
71
|
+
# Never shows the API key, in whole or in part.
|
|
72
|
+
return "KRXSession(...)"
|
|
73
|
+
|
|
74
|
+
def fetch(self, endpoint: KRXEndpoint, **params: str) -> list[Row]:
|
|
75
|
+
"""Fetch one service and return its ``OutBlock_*`` rows.
|
|
76
|
+
|
|
77
|
+
``params`` are the query arguments the endpoint takes -- for the daily
|
|
78
|
+
endpoints, ``basDd="YYYYMMDD"``. A non-empty ``respCode`` raises the matching
|
|
79
|
+
:class:`KRXResponseError` (``401`` -> :class:`KRXAuthError`); HTTP 429 raises
|
|
80
|
+
:class:`KRXRateLimitError`; a transport or non-JSON failure raises
|
|
81
|
+
:class:`KRXNetworkError`; an empty ``OutBlock_*`` (a no-data date) returns ``[]``.
|
|
82
|
+
"""
|
|
83
|
+
return self._get(endpoint.url, self.api_key, params)
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def fetch_sample(cls, endpoint: KRXEndpoint, *, timeout: float = 60.0) -> list[Row]:
|
|
87
|
+
"""Fetch an endpoint's keyless sample (fixed :data:`SAMPLE_DATE`).
|
|
88
|
+
|
|
89
|
+
A classmethod: it builds its own session from the public sample key, so it
|
|
90
|
+
needs no configured ``KRX_API_KEY`` at all. Hits the ``/svc/sample/apis``
|
|
91
|
+
twin, never a real service.
|
|
92
|
+
"""
|
|
93
|
+
session = cls(SAMPLE_KEY, timeout=timeout)
|
|
94
|
+
return session._get(endpoint.sample_url, SAMPLE_KEY, {"basDd": SAMPLE_DATE})
|
|
95
|
+
|
|
96
|
+
def _get(self, url: str, api_key: str, params: dict[str, str]) -> list[Row]:
|
|
97
|
+
query = urllib.parse.urlencode({k: v for k, v in params.items() if v is not None})
|
|
98
|
+
full_url = f"{url}?{query}" if query else url
|
|
99
|
+
request = urllib.request.Request(
|
|
100
|
+
full_url, headers={_AUTH_HEADER: api_key.strip(), "User-Agent": _USER_AGENT}
|
|
101
|
+
)
|
|
102
|
+
try:
|
|
103
|
+
with _OPENER.open(request, timeout=self.timeout) as response:
|
|
104
|
+
raw = response.read()
|
|
105
|
+
except urllib.error.HTTPError as err:
|
|
106
|
+
with err: # an HTTPError is an unclosed response; release its socket
|
|
107
|
+
if err.code == _RATE_LIMIT_STATUS:
|
|
108
|
+
raise KRXRateLimitError(
|
|
109
|
+
"KRX daily call quota exceeded (10,000/key/day); resets at "
|
|
110
|
+
"midnight") from err
|
|
111
|
+
hint = ""
|
|
112
|
+
if err.code == _FORBIDDEN_STATUS:
|
|
113
|
+
hint = (" -- check the key, that the URL is https and /svc/apis "
|
|
114
|
+
"(not /svc/sample/apis with a real key)")
|
|
115
|
+
raise KRXNetworkError(f"HTTP {err.code} from KRX for {url}{hint}") from err
|
|
116
|
+
except urllib.error.URLError as err:
|
|
117
|
+
raise KRXNetworkError(f"request to KRX failed: {err.reason}") from err
|
|
118
|
+
except (http.client.HTTPException, OSError) as err:
|
|
119
|
+
# A failure during response.read() (IncompleteRead, a socket timeout or
|
|
120
|
+
# reset) is not an HTTPError/URLError; surface it through KRXError too.
|
|
121
|
+
raise KRXNetworkError(f"KRX response read failed for {url}: {err}") from err
|
|
122
|
+
return _rows_from_body(raw, url)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _rows_from_body(raw: bytes, url: str) -> list[Row]:
|
|
126
|
+
"""Apply the KRX status contract to a raw response body.
|
|
127
|
+
|
|
128
|
+
A pure function of the bytes: parses JSON, raises on a non-empty ``respCode`` or a
|
|
129
|
+
non-JSON body, and returns the single ``OutBlock_*`` array (``[]`` when the vendor
|
|
130
|
+
sends an empty one for a no-data date).
|
|
131
|
+
"""
|
|
132
|
+
try:
|
|
133
|
+
# Decode as UTF-8 explicitly: json.loads(bytes) auto-detects UTF-16/32, which
|
|
134
|
+
# would let a non-UTF-8 body slip through the "must be UTF-8 JSON" contract.
|
|
135
|
+
body: Any = json.loads(raw.decode("utf-8"))
|
|
136
|
+
except (json.JSONDecodeError, UnicodeDecodeError, RecursionError) as err:
|
|
137
|
+
# A 200 whose body is not JSON / not UTF-8 (a proxy or maintenance HTML page), or
|
|
138
|
+
# is nested deep enough to blow the parser's recursion limit, must surface through
|
|
139
|
+
# KRXError, not as a raw decode/recursion error.
|
|
140
|
+
raise KRXNetworkError(f"non-JSON response from KRX for {url}") from err
|
|
141
|
+
if not isinstance(body, dict):
|
|
142
|
+
raise KRXNetworkError(f"unexpected KRX response for {url}: {body!r}")
|
|
143
|
+
|
|
144
|
+
code = body.get("respCode")
|
|
145
|
+
if code: # present only on failure
|
|
146
|
+
raise _error_for(str(code), str(body.get("respMsg", "")))
|
|
147
|
+
|
|
148
|
+
blocks = [
|
|
149
|
+
value for key, value in body.items()
|
|
150
|
+
if key.startswith("OutBlock_") and isinstance(value, list)
|
|
151
|
+
]
|
|
152
|
+
if len(blocks) != 1:
|
|
153
|
+
# Success must carry exactly one OutBlock_* array; zero or several is a shape
|
|
154
|
+
# this contract does not recognize (not a silent empty result).
|
|
155
|
+
raise KRXNetworkError(
|
|
156
|
+
f"expected exactly one OutBlock_* array from KRX for {url}, got {len(blocks)}")
|
|
157
|
+
rows = blocks[0]
|
|
158
|
+
if not all(isinstance(row, dict) for row in rows):
|
|
159
|
+
raise KRXNetworkError(f"a non-object row in KRX OutBlock for {url}")
|
|
160
|
+
return rows
|
krx_openapi/types.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Shared type aliases for krx-openapi.
|
|
2
|
+
|
|
3
|
+
KRX returns every field as a string (numbers included, a missing value as ``""``),
|
|
4
|
+
so a row is a plain ``dict[str, str]`` -- ``Row``. The client passes rows through
|
|
5
|
+
untouched, which is why there is no per-endpoint ``TypedDict`` here: the field set
|
|
6
|
+
is the vendor's, documented in :mod:`krx_openapi.catalog`, and a caller frames a
|
|
7
|
+
list of rows into a table in one line (``pandas.DataFrame(rows)`` /
|
|
8
|
+
``polars.DataFrame(rows)``) and coerces the columns they need.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Literal
|
|
14
|
+
|
|
15
|
+
Row = dict[str, str]
|
|
16
|
+
|
|
17
|
+
# The three cash-equity markets, by their common names. The client maps these to
|
|
18
|
+
# KRX's internal segment codes (KOSPI->stk, KOSDAQ->ksq, KONEX->knx) when picking
|
|
19
|
+
# the endpoint; the name never goes on the wire.
|
|
20
|
+
Market = Literal["KOSPI", "KOSDAQ", "KONEX"]
|
|
21
|
+
|
|
22
|
+
# The subset that has stock futures/options: KRX lists these only for the KOSPI and
|
|
23
|
+
# KOSDAQ markets, never KONEX. A narrower type than Market so an unsupported KONEX is
|
|
24
|
+
# rejected by the type checker, not only at runtime.
|
|
25
|
+
StockDerivMarket = Literal["KOSPI", "KOSDAQ"]
|
|
26
|
+
|
|
27
|
+
# The seven service path codes under /svc/apis/<Category>/<api_id>.
|
|
28
|
+
Category = Literal["idx", "sto", "etp", "bon", "drv", "gen", "esg"]
|