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/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"]