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.
@@ -0,0 +1,55 @@
1
+ """krx-openapi -- read daily market data from the KRX Open API.
2
+
3
+ from krx_openapi import KRX
4
+
5
+ krx = KRX() # or set KRX_API_KEY
6
+ rows = krx.index.kospi("20200414") # KOSPI series, one day
7
+ stocks = krx.stock.daily("20200414", market="KOSPI")
8
+
9
+ Reads the KRX Open API: indices, stocks and
10
+ the issue master, ETF/ETN/ELW, bonds, futures/options, oil/gold/emissions, and ESG.
11
+ Returns raw ``list[dict]`` with the vendor's own field names -- frame it your own
12
+ way, e.g. ``pandas.DataFrame(rows)`` or ``polars.DataFrame(rows)``. The offline
13
+ :mod:`krx_openapi.catalog` describes every service and its fields without a call.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from importlib.metadata import PackageNotFoundError, version
19
+
20
+ from . import catalog
21
+ from ._endpoint import ENDPOINTS, KRXEndpoint
22
+ from .client import KRX
23
+ from .errors import (
24
+ KRXAuthError,
25
+ KRXConfigError,
26
+ KRXError,
27
+ KRXNetworkError,
28
+ KRXRateLimitError,
29
+ KRXResponseError,
30
+ )
31
+ from .session import KRXSession
32
+ from .types import Category, Market, Row, StockDerivMarket
33
+
34
+ try:
35
+ __version__ = version("krx-openapi") # single source of truth: pyproject.toml
36
+ except PackageNotFoundError: # running from source without an install
37
+ __version__ = "0.0.0+unknown"
38
+
39
+ __all__ = [
40
+ "ENDPOINTS",
41
+ "KRX",
42
+ "Category",
43
+ "KRXAuthError",
44
+ "KRXConfigError",
45
+ "KRXEndpoint",
46
+ "KRXError",
47
+ "KRXNetworkError",
48
+ "KRXRateLimitError",
49
+ "KRXResponseError",
50
+ "KRXSession",
51
+ "Market",
52
+ "Row",
53
+ "StockDerivMarket",
54
+ "catalog",
55
+ ]
@@ -0,0 +1,8 @@
1
+ """``python -m krx_openapi`` -- same entry point as the ``krx`` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
krx_openapi/_config.py ADDED
@@ -0,0 +1,74 @@
1
+ """Resolve the KRX API key from the caller, the environment, or the config file.
2
+
3
+ The key is looked up in a fixed order, so an explicit value always wins and a set
4
+ environment variable beats a file on disk:
5
+
6
+ 1. the ``api_key`` passed to ``KRX(...)``
7
+ 2. the ``KRX_API_KEY`` environment variable
8
+ 3. ``"KRX_API_KEY"`` in ``$XDG_CONFIG_HOME/krx-openapi/credentials.json``
9
+ (``$XDG_CONFIG_HOME`` defaults to ``~/.config``)
10
+
11
+ The file is optional -- its absence just means "no key here." But a file that is
12
+ present and unreadable, not JSON, or not a JSON object is an error, because a caller
13
+ who wrote one meant it to be used and a silent skip would hide the mistake.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ from pathlib import Path
21
+
22
+ from .errors import KRXConfigError
23
+
24
+ _ENV_VAR = "KRX_API_KEY"
25
+ _CONFIG_DIR = "krx-openapi"
26
+ _CONFIG_FILE = "credentials.json"
27
+
28
+
29
+ def resolve_api_key(explicit: str | None) -> str:
30
+ """Return the first key found across the three sources, or raise if none exists."""
31
+ key = (explicit or "").strip() or os.environ.get(_ENV_VAR, "").strip() or _key_from_file()
32
+ if not key:
33
+ raise KRXConfigError(
34
+ f"no KRX API key: pass api_key=, set the {_ENV_VAR} environment "
35
+ f"variable, or put it in {credentials_path()}"
36
+ )
37
+ if any(ord(ch) < 0x20 or ord(ch) == 0x7f for ch in key):
38
+ # A control character (a stray newline/tab, often from a copy-paste) would make
39
+ # an invalid HTTP header: urllib raises ValueError echoing the whole value -- the
40
+ # key. Reject it as a config error, before it becomes a request, and never echo it.
41
+ raise KRXConfigError(
42
+ "the KRX API key contains a control character (a stray newline or tab?)")
43
+ return key
44
+
45
+
46
+ def credentials_path() -> Path:
47
+ """The path krx-openapi reads a stored key from (honoring ``$XDG_CONFIG_HOME``)."""
48
+ config_home = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
49
+ return Path(config_home) / _CONFIG_DIR / _CONFIG_FILE
50
+
51
+
52
+ def _key_from_file() -> str:
53
+ path = credentials_path()
54
+ try:
55
+ text = path.read_text(encoding="utf-8")
56
+ except FileNotFoundError:
57
+ return ""
58
+ except UnicodeDecodeError as err:
59
+ # A present file that is not UTF-8 is "unreadable" in the same sense as an
60
+ # OSError: the caller wrote it meaning it to be used, so surface it, not a
61
+ # raw decode error. (UnicodeDecodeError is a ValueError, not an OSError.)
62
+ raise KRXConfigError(f"{path} is not valid UTF-8: {err}") from err
63
+ except OSError as err:
64
+ raise KRXConfigError(f"could not read {path}: {err}") from err
65
+
66
+ try:
67
+ data = json.loads(text)
68
+ except json.JSONDecodeError as err:
69
+ raise KRXConfigError(f"{path} is not valid JSON: {err}") from err
70
+ if not isinstance(data, dict):
71
+ raise KRXConfigError(f"{path} must contain a JSON object")
72
+
73
+ key = data.get(_ENV_VAR)
74
+ return key.strip() if isinstance(key, str) else ""
@@ -0,0 +1,98 @@
1
+ """The KRXEndpoint value object and the registry of every KRX Open API service.
2
+
3
+ Each endpoint is data: its service path code (``category``), its ``api_id``, the
4
+ name of its field schema (:mod:`krx_openapi.catalog`), its Korean title, and the
5
+ earliest ``basDd`` for which KRX serves data. ``url`` and ``sample_url`` resolve
6
+ from those -- the wire location lives in exactly one place.
7
+
8
+ KRX also publishes a keyless *sample* twin of every endpoint: the same shape at a
9
+ fixed sample date, reachable with a public sample key -- :attr:`KRXEndpoint.sample_url`,
10
+ :data:`SAMPLE_KEY`, and :data:`SAMPLE_DATE` describe it.
11
+
12
+ ``ENDPOINTS`` is exposed read-only (a ``MappingProxyType``): it is package dispatch
13
+ state that every category accessor reads, so a caller must not be able to mutate it.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections.abc import Mapping
19
+ from dataclasses import dataclass
20
+ from types import MappingProxyType
21
+
22
+ from .types import Category
23
+
24
+ BASE_URL = "https://data-dbg.krx.co.kr/svc/apis"
25
+ SAMPLE_URL = "https://data-dbg.krx.co.kr/svc/sample/apis"
26
+
27
+ # Published by KRX on its own API test page -- a public sample key, not a secret.
28
+ # It reaches only the sample endpoints, which always answer for SAMPLE_DATE.
29
+ SAMPLE_KEY = "74D1B99DFBF345BBA3FB4476510A4BED4C78D13A"
30
+ SAMPLE_DATE = "20200414"
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class KRXEndpoint:
35
+ """One KRX Open API service and where its data and spec live."""
36
+
37
+ category: Category # service path code: idx, sto, etp, bon, drv, gen, esg
38
+ api_id: str # e.g. "krx_dd_trd"
39
+ schema: str = "" # field-schema name, keys into krx_openapi.catalog
40
+ name_ko: str = "" # Korean title, e.g. "KRX 시리즈 일별시세정보"
41
+ start: str = "" # earliest basDd with data, e.g. "2010-01-04"
42
+
43
+ @property
44
+ def url(self) -> str:
45
+ # .json makes the JSON format explicit (KRX's documented contract) and https
46
+ # avoids the http -> https 302 that would drop the AUTH_KEY header.
47
+ return f"{BASE_URL}/{self.category}/{self.api_id}.json"
48
+
49
+ @property
50
+ def sample_url(self) -> str:
51
+ return f"{SAMPLE_URL}/{self.category}/{self.api_id}.json"
52
+
53
+
54
+ def _registry(*endpoints: KRXEndpoint) -> Mapping[str, KRXEndpoint]:
55
+ return MappingProxyType({endpoint.api_id: endpoint for endpoint in endpoints})
56
+
57
+
58
+ # Every service, grouped by category. api_id -> KRXEndpoint. Read-only.
59
+ ENDPOINTS: Mapping[str, KRXEndpoint] = _registry(
60
+ # -- 지수 (idx) --------------------------------------------------------
61
+ KRXEndpoint("idx", "krx_dd_trd", "IDX_DAILY", "KRX 시리즈 일별시세정보", "2010-01-04"),
62
+ KRXEndpoint("idx", "kospi_dd_trd", "IDX_DAILY", "KOSPI 시리즈 일별시세정보", "2010-01-04"),
63
+ KRXEndpoint("idx", "kosdaq_dd_trd", "IDX_DAILY", "KOSDAQ 시리즈 일별시세정보", "2010-01-04"),
64
+ KRXEndpoint("idx", "bon_dd_trd", "BOND_IDX", "채권지수 시세정보", "2010-01-04"),
65
+ KRXEndpoint("idx", "drvprod_dd_trd", "DRVPROD_IDX", "파생상품지수 시세정보", "2010-01-04"),
66
+ # -- 주식 (sto) --------------------------------------------------------
67
+ KRXEndpoint("sto", "stk_bydd_trd", "STOCK_TRD", "유가증권 일별매매정보", "2010-01-04"),
68
+ KRXEndpoint("sto", "ksq_bydd_trd", "STOCK_TRD", "코스닥 일별매매정보", "2010-01-04"),
69
+ KRXEndpoint("sto", "knx_bydd_trd", "STOCK_TRD", "코넥스 일별매매정보", "2013-07-01"),
70
+ KRXEndpoint("sto", "sw_bydd_trd", "WARRANT_SW", "신주인수권증권 일별매매정보", "2010-01-04"),
71
+ KRXEndpoint("sto", "sr_bydd_trd", "WARRANT_SR", "신주인수권증서 일별매매정보", "2010-02-12"),
72
+ KRXEndpoint("sto", "stk_isu_base_info", "ISU_BASE", "유가증권 종목기본정보", "2010-01-04"),
73
+ KRXEndpoint("sto", "ksq_isu_base_info", "ISU_BASE", "코스닥 종목기본정보", "2010-01-04"),
74
+ KRXEndpoint("sto", "knx_isu_base_info", "ISU_BASE", "코넥스 종목기본정보", "2013-07-01"),
75
+ # -- 증권상품 (etp) ----------------------------------------------------
76
+ KRXEndpoint("etp", "etf_bydd_trd", "ETF_TRD", "ETF 일별매매정보", "2010-01-04"),
77
+ KRXEndpoint("etp", "etn_bydd_trd", "ETN_TRD", "ETN 일별매매정보", "2014-11-17"),
78
+ KRXEndpoint("etp", "elw_bydd_trd", "ELW_TRD", "ELW 일별매매정보", "2010-01-04"),
79
+ # -- 채권 (bon) --------------------------------------------------------
80
+ KRXEndpoint("bon", "kts_bydd_trd", "GOVBOND_TRD", "국채전문유통시장 일별매매정보", "2010-01-04"),
81
+ KRXEndpoint("bon", "bnd_bydd_trd", "BOND_TRD", "일반채권시장 일별매매정보", "2010-01-04"),
82
+ KRXEndpoint("bon", "smb_bydd_trd", "BOND_TRD", "소액채권시장 일별매매정보", "2010-01-04"),
83
+ # -- 파생상품 (drv) ----------------------------------------------------
84
+ KRXEndpoint("drv", "fut_bydd_trd", "FUTURES_TRD", "선물 일별매매정보 (주식선물外)", "2010-01-04"),
85
+ KRXEndpoint("drv", "eqsfu_stk_bydd_trd", "FUTURES_TRD", "주식선물(유가) 일별매매정보", "2010-01-04"),
86
+ KRXEndpoint("drv", "eqkfu_ksq_bydd_trd", "FUTURES_TRD", "주식선물(코스닥) 일별매매정보", "2015-08-03"),
87
+ KRXEndpoint("drv", "opt_bydd_trd", "OPTION_TRD", "옵션 일별매매정보 (주식옵션外)", "2010-01-04"),
88
+ KRXEndpoint("drv", "eqsop_bydd_trd", "OPTION_TRD", "주식옵션(유가) 일별매매정보", "2010-01-04"),
89
+ KRXEndpoint("drv", "eqkop_bydd_trd", "OPTION_TRD", "주식옵션(코스닥) 일별매매정보", "2017-06-26"),
90
+ # -- 일반상품 (gen) ----------------------------------------------------
91
+ KRXEndpoint("gen", "oil_bydd_trd", "OIL_TRD", "석유시장 일별매매정보", "2012-03-30"),
92
+ KRXEndpoint("gen", "gold_bydd_trd", "GOLD_ETS_TRD", "금시장 일별매매정보", "2014-03-24"),
93
+ KRXEndpoint("gen", "ets_bydd_trd", "GOLD_ETS_TRD", "배출권 시장 일별매매정보", "2015-01-12"),
94
+ # -- ESG (esg) ---------------------------------------------------------
95
+ KRXEndpoint("esg", "sri_bond_info", "SRI_BOND", "사회책임투자채권 정보", "2019-01-01"),
96
+ KRXEndpoint("esg", "esg_index_info", "ESG_IDX", "ESG 지수", "2020-01-02"),
97
+ KRXEndpoint("esg", "esg_etp_info", "ESG_ETP", "ESG 증권상품", "2020-01-02"),
98
+ )
krx_openapi/catalog.py ADDED
@@ -0,0 +1,198 @@
1
+ """The offline catalog -- every service and its field schema, no network needed.
2
+
3
+ The 31 endpoints share 19 distinct field schemas (the index-daily endpoints all
4
+ carry the same twelve fields; the three stock-trade endpoints another fifteen; and
5
+ so on). :data:`SCHEMAS` maps each schema name to its field tuple -- the vendor's own
6
+ ``OutBlock_*`` field names, in order -- so a caller can see what a call returns
7
+ before making it. :func:`fields` turns a readable ``group name`` into the columns
8
+ that service returns.
9
+
10
+ ``SCHEMAS`` is exposed read-only (a ``MappingProxyType`` of tuples): it is
11
+ authoritative package state, so a caller must not be able to mutate it. Every value
12
+ KRX returns is a string; these are the field *names*, not types.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Mapping
18
+ from types import MappingProxyType
19
+
20
+ from ._endpoint import ENDPOINTS, KRXEndpoint
21
+
22
+ # schema name -> its OutBlock field names, in the order KRX returns them.
23
+ SCHEMAS: Mapping[str, tuple[str, ...]] = MappingProxyType({
24
+ "IDX_DAILY": (
25
+ "BAS_DD", "IDX_CLSS", "IDX_NM", "CLSPRC_IDX", "CMPPREVDD_IDX", "FLUC_RT",
26
+ "OPNPRC_IDX", "HGPRC_IDX", "LWPRC_IDX", "ACC_TRDVOL", "ACC_TRDVAL", "MKTCAP",
27
+ ),
28
+ "BOND_IDX": (
29
+ "BAS_DD", "BND_IDX_GRP_NM", "TOT_EARNG_IDX", "TOT_EARNG_IDX_CMPPREVDD",
30
+ "NETPRC_IDX", "NETPRC_IDX_CMPPREVDD", "ZERO_REINVST_IDX",
31
+ "ZERO_REINVST_IDX_CMPPREVDD", "CALL_REINVST_IDX", "CALL_REINVST_IDX_CMPPREVDD",
32
+ "MKT_PRC_IDX", "MKT_PRC_IDX_CMPPREVDD", "AVG_DURATION", "AVG_CONVEXITY_PRC",
33
+ "BND_IDX_AVG_YD",
34
+ ),
35
+ "DRVPROD_IDX": (
36
+ "BAS_DD", "IDX_CLSS", "IDX_NM", "CLSPRC_IDX", "CMPPREVDD_IDX", "FLUC_RT",
37
+ "OPNPRC_IDX", "HGPRC_IDX", "LWPRC_IDX",
38
+ ),
39
+ "STOCK_TRD": (
40
+ "BAS_DD", "ISU_CD", "ISU_NM", "MKT_NM", "SECT_TP_NM", "TDD_CLSPRC",
41
+ "CMPPREVDD_PRC", "FLUC_RT", "TDD_OPNPRC", "TDD_HGPRC", "TDD_LWPRC",
42
+ "ACC_TRDVOL", "ACC_TRDVAL", "MKTCAP", "LIST_SHRS",
43
+ ),
44
+ "WARRANT_SW": (
45
+ "BAS_DD", "MKT_NM", "ISU_CD", "ISU_NM", "TDD_CLSPRC", "CMPPREVDD_PRC",
46
+ "FLUC_RT", "TDD_OPNPRC", "TDD_HGPRC", "TDD_LWPRC", "ACC_TRDVOL", "ACC_TRDVAL",
47
+ "MKTCAP", "LIST_SHRS", "EXER_PRC", "EXST_STRT_DD", "EXST_END_DD",
48
+ "TARSTK_ISU_SRT_CD", "TARSTK_ISU_NM", "TARSTK_ISU_PRSNT_PRC",
49
+ ),
50
+ "WARRANT_SR": (
51
+ "BAS_DD", "MKT_NM", "ISU_CD", "ISU_NM", "TDD_CLSPRC", "CMPPREVDD_PRC",
52
+ "FLUC_RT", "TDD_OPNPRC", "TDD_HGPRC", "TDD_LWPRC", "ACC_TRDVOL", "ACC_TRDVAL",
53
+ "MKTCAP", "LIST_SHRS", "ISU_PRC", "DELIST_DD", "TARSTK_ISU_SRT_CD",
54
+ "TARSTK_ISU_NM", "TARSTK_ISU_PRSNT_PRC",
55
+ ),
56
+ "ISU_BASE": (
57
+ "ISU_CD", "ISU_SRT_CD", "ISU_NM", "ISU_ABBRV", "ISU_ENG_NM", "LIST_DD",
58
+ "MKT_TP_NM", "SECUGRP_NM", "SECT_TP_NM", "KIND_STKCERT_TP_NM", "PARVAL",
59
+ "LIST_SHRS",
60
+ ),
61
+ "ETF_TRD": (
62
+ "BAS_DD", "ISU_CD", "ISU_NM", "TDD_CLSPRC", "CMPPREVDD_PRC", "FLUC_RT", "NAV",
63
+ "TDD_OPNPRC", "TDD_HGPRC", "TDD_LWPRC", "ACC_TRDVOL", "ACC_TRDVAL", "MKTCAP",
64
+ "INVSTASST_NETASST_TOTAMT", "LIST_SHRS", "IDX_IND_NM", "OBJ_STKPRC_IDX",
65
+ "CMPPREVDD_IDX", "FLUC_RT_IDX",
66
+ ),
67
+ "ETN_TRD": (
68
+ "BAS_DD", "ISU_CD", "ISU_NM", "TDD_CLSPRC", "CMPPREVDD_PRC", "FLUC_RT",
69
+ "PER1SECU_INDIC_VAL", "TDD_OPNPRC", "TDD_HGPRC", "TDD_LWPRC", "ACC_TRDVOL",
70
+ "ACC_TRDVAL", "MKTCAP", "INDIC_VAL_AMT", "LIST_SHRS", "IDX_IND_NM",
71
+ "OBJ_STKPRC_IDX", "CMPPREVDD_IDX", "FLUC_RT_IDX",
72
+ ),
73
+ "ELW_TRD": (
74
+ "BAS_DD", "ISU_CD", "ISU_NM", "TDD_CLSPRC", "CMPPREVDD_PRC", "TDD_OPNPRC",
75
+ "TDD_HGPRC", "TDD_LWPRC", "ACC_TRDVOL", "ACC_TRDVAL", "MKTCAP", "LIST_SHRS",
76
+ "ULY_NM", "ULY_PRC", "CMPPREVDD_PRC_ULY", "FLUC_RT_ULY",
77
+ ),
78
+ "GOVBOND_TRD": (
79
+ "BAS_DD", "MKT_NM", "ISU_CD", "ISU_NM", "BND_EXP_TP_NM", "GOVBND_ISU_TP_NM",
80
+ "CLSPRC", "CMPPREVDD_PRC", "CLSPRC_YD", "OPNPRC", "OPNPRC_YD", "HGPRC",
81
+ "HGPRC_YD", "LWPRC", "LWPRC_YD", "ACC_TRDVOL", "ACC_TRDVAL",
82
+ ),
83
+ "BOND_TRD": (
84
+ "BAS_DD", "MKT_NM", "ISU_CD", "ISU_NM", "CLSPRC", "CMPPREVDD_PRC", "CLSPRC_YD",
85
+ "OPNPRC", "OPNPRC_YD", "HGPRC", "HGPRC_YD", "LWPRC", "LWPRC_YD", "ACC_TRDVOL",
86
+ "ACC_TRDVAL",
87
+ ),
88
+ "FUTURES_TRD": (
89
+ "BAS_DD", "PROD_NM", "MKT_NM", "ISU_CD", "ISU_NM", "TDD_CLSPRC",
90
+ "CMPPREVDD_PRC", "TDD_OPNPRC", "TDD_HGPRC", "TDD_LWPRC", "SPOT_PRC", "SETL_PRC",
91
+ "ACC_TRDVOL", "ACC_TRDVAL", "ACC_OPNINT_QTY",
92
+ ),
93
+ "OPTION_TRD": (
94
+ "BAS_DD", "PROD_NM", "RGHT_TP_NM", "ISU_CD", "ISU_NM", "TDD_CLSPRC",
95
+ "CMPPREVDD_PRC", "TDD_OPNPRC", "TDD_HGPRC", "TDD_LWPRC", "IMP_VOLT",
96
+ "NXTDD_BAS_PRC", "ACC_TRDVOL", "ACC_TRDVAL", "ACC_OPNINT_QTY",
97
+ ),
98
+ "OIL_TRD": (
99
+ "BAS_DD", "OIL_NM", "WT_AVG_PRC", "WT_DIS_AVG_PRC", "ACC_TRDVOL", "ACC_TRDVAL",
100
+ ),
101
+ "GOLD_ETS_TRD": (
102
+ "BAS_DD", "ISU_CD", "ISU_NM", "TDD_CLSPRC", "CMPPREVDD_PRC", "FLUC_RT",
103
+ "TDD_OPNPRC", "TDD_HGPRC", "TDD_LWPRC", "ACC_TRDVOL", "ACC_TRDVAL",
104
+ ),
105
+ "ESG_ETP": (
106
+ "BAS_DD", "ISU_ABBRV", "TDD_CLSPRC", "CMPPREVDD_PRC", "FLUC_RT", "LIST_SHRS",
107
+ "ACC_TRDVOL", "ACC_TRDVAL",
108
+ ),
109
+ "SRI_BOND": (
110
+ "BAS_DD", "ISUR_NM", "ISU_CD", "SRI_BND_TP_NM", "ISU_NM", "LIST_DD", "ISU_DD",
111
+ "REDMPT_DD", "ISU_RT", "ISU_AMT", "LIST_AMT", "BND_TP_NM",
112
+ ),
113
+ "ESG_IDX": (
114
+ "BAS_DD", "IDX_NM", "CLSPRC_IDX", "PRV_DD_CMPR", "UPDN_RATE", "TRD_ISU_CNT",
115
+ "ACC_TRDVOL", "ACC_TRDVAL",
116
+ ),
117
+ })
118
+
119
+
120
+ # The KRX client's accessor tree as data: group -> [(method name, representative
121
+ # api_id)]. It mirrors the KRX / _Surface accessors so the CLI can offer the same
122
+ # readable names (`krx fetch index kospi`) instead of raw api_ids, and drive `list` /
123
+ # `fields` offline. The api_id is only used to look up a field schema, and every
124
+ # market variant of a method shares one schema, so a single representative suffices.
125
+ ACCESSORS: Mapping[str, tuple[tuple[str, str], ...]] = MappingProxyType({
126
+ "index": (
127
+ ("krx", "krx_dd_trd"), ("kospi", "kospi_dd_trd"), ("kosdaq", "kosdaq_dd_trd"),
128
+ ("bond", "bon_dd_trd"), ("derivatives", "drvprod_dd_trd"),
129
+ ),
130
+ "stock": (
131
+ ("info", "stk_isu_base_info"), ("daily", "stk_bydd_trd"),
132
+ ("warrant", "sw_bydd_trd"), ("right", "sr_bydd_trd"),
133
+ ),
134
+ "etp": (("etf", "etf_bydd_trd"), ("etn", "etn_bydd_trd"), ("elw", "elw_bydd_trd")),
135
+ "bond": (
136
+ ("treasury", "kts_bydd_trd"), ("general", "bnd_bydd_trd"),
137
+ ("small_lot", "smb_bydd_trd"),
138
+ ),
139
+ "derivatives": (
140
+ ("futures", "fut_bydd_trd"), ("options", "opt_bydd_trd"),
141
+ ("stock_futures", "eqsfu_stk_bydd_trd"), ("stock_options", "eqsop_bydd_trd"),
142
+ ),
143
+ "commodity": (
144
+ ("oil", "oil_bydd_trd"), ("gold", "gold_bydd_trd"), ("emissions", "ets_bydd_trd"),
145
+ ),
146
+ "esg": (
147
+ ("sri_bond", "sri_bond_info"), ("index", "esg_index_info"), ("etp", "esg_etp_info"),
148
+ ),
149
+ })
150
+
151
+
152
+ def groups() -> list[str]:
153
+ """The client accessor groups, in tree order (``index``, ``stock``, ...)."""
154
+ return list(ACCESSORS)
155
+
156
+
157
+ def methods(group: str) -> list[str]:
158
+ """The method names under one accessor group (raises ``KeyError`` if unknown)."""
159
+ return [name for name, _ in ACCESSORS[group]]
160
+
161
+
162
+ def fields(group: str, name: str) -> list[str]:
163
+ """The field (column) names an accessor ``group.name`` returns, in order.
164
+
165
+ catalog.fields("index", "kospi") # -> ["BAS_DD", "IDX_CLSS", ..., "MKTCAP"]
166
+
167
+ Reads the bundled schema -- no network, no key. Raises ``KeyError`` for an
168
+ unknown group or method (the same signal the client gives).
169
+ """
170
+ return _schema_fields(_api_id_for(group, name))
171
+
172
+
173
+ def _api_id_for(group: str, name: str) -> str:
174
+ """Translate an accessor ``group.name`` to the KRX api_id it maps to -- the bridge
175
+ from the readable names to the api_id-keyed schema data. Internal: users and the
176
+ CLI go through :func:`fields`, never an api_id."""
177
+ for method_name, api_id in ACCESSORS[group]:
178
+ if method_name == name:
179
+ return api_id
180
+ raise KeyError(f"{group}.{name}")
181
+
182
+
183
+ def _schema_fields(api_id: str) -> list[str]:
184
+ """The field names of an api_id's schema, in order. Internal: reached through
185
+ :func:`fields` by accessor name, never by a raw api_id."""
186
+ return list(SCHEMAS[ENDPOINTS[api_id].schema])
187
+
188
+
189
+ def endpoints() -> list[KRXEndpoint]:
190
+ """Every KRX endpoint, in registry order."""
191
+ return list(ENDPOINTS.values())
192
+
193
+
194
+ def schemas() -> dict[str, list[str]]:
195
+ """A mutable copy of the schema-name -> field-list map."""
196
+ return {name: list(field_names) for name, field_names in SCHEMAS.items()}
197
+
198
+
krx_openapi/cli.py ADDED
@@ -0,0 +1,170 @@
1
+ """Command-line shell over ``KRX`` -- three commands, one per plugin skill.
2
+
3
+ ``list`` and ``fields`` browse the bundled catalog offline (no key); ``fetch`` runs
4
+ one service for a date. Each takes the readable ``group name`` pair the Python client
5
+ uses -- ``krx fetch index kospi 20200414`` fetches what
6
+ ``krx.index.kospi("20200414")`` returns. No api_ids to memorize.
7
+
8
+ $ krx list # every group and its methods (offline)
9
+ $ krx list stock # just one group (offline)
10
+ $ krx fields index kospi # the columns that service returns (offline)
11
+ $ krx fetch index kospi 20200414
12
+ $ krx fetch stock daily 20200414 --market KOSDAQ
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import sys
20
+ from collections.abc import Callable, Sequence
21
+
22
+ from . import __version__, catalog
23
+ from .client import KRX, _accepts_market
24
+ from .errors import KRXError
25
+ from .types import Row
26
+
27
+ _PROG = "krx"
28
+ _ERROR_PREFIX = f"{_PROG}: "
29
+
30
+ # How many rows the text view prints; the full result is always in --json.
31
+ _MAX_SHOWN_ROWS = 20
32
+
33
+
34
+ def main(argv: Sequence[str] | None = None) -> int:
35
+ """Parse ``argv``, run one call, and return a process exit code.
36
+
37
+ A failure -- a missing/rejected key, a vendor error, or a transport problem -- is
38
+ printed as a one-line ``krx: <message>`` to stderr and returns 1. A usage error
39
+ caught here (an unknown service, or ``--market`` on a service without it) returns
40
+ 2; argparse's own usage errors (a bad flag or subcommand) raise ``SystemExit(2)``.
41
+ """
42
+ args = _make_parser().parse_args(argv)
43
+ run: Callable[[argparse.Namespace], int] = args.run
44
+ try:
45
+ return run(args)
46
+ except KRXError as err:
47
+ print(f"{_ERROR_PREFIX}{err}", file=sys.stderr)
48
+ return 1
49
+
50
+
51
+ def _make_parser() -> argparse.ArgumentParser:
52
+ parser = argparse.ArgumentParser(
53
+ prog=_PROG, description="Read the KRX Open API from the command line.")
54
+ parser.add_argument("--version", action="version", version=f"{_PROG} {__version__}")
55
+ commands = parser.add_subparsers(required=True)
56
+
57
+ # Registered list -> fields -> fetch: discovery first, then the key-gated fetch.
58
+ list_cmd = commands.add_parser("list", help="list services (offline)")
59
+ list_cmd.add_argument("group", nargs="?", choices=catalog.groups(), default=None,
60
+ help="only this group; omit for all")
61
+ list_cmd.add_argument("--json", action="store_true", help="emit JSON instead of text")
62
+ list_cmd.set_defaults(run=_run_list)
63
+
64
+ fields_cmd = commands.add_parser("fields", help="a service's field schema (offline)")
65
+ fields_cmd.add_argument("group", choices=catalog.groups(), help="accessor group")
66
+ fields_cmd.add_argument("method", help="service method (e.g. kospi)")
67
+ fields_cmd.add_argument("--json", action="store_true", help="emit JSON instead of text")
68
+ fields_cmd.set_defaults(run=_run_fields)
69
+
70
+ # `krx fetch <group> <method> <date>` -- the one data command, mirroring the `fetch`
71
+ # skill. group is validated here; the method depends on the group, which argparse
72
+ # choices cannot express, so _run_fetch checks it against `catalog.methods(group)`.
73
+ fetch_cmd = commands.add_parser("fetch", help="fetch one day of a service")
74
+ fetch_cmd.add_argument("group", choices=catalog.groups(), help="accessor group")
75
+ fetch_cmd.add_argument("method", help="service method, e.g. kospi (see `krx list <group>`)")
76
+ fetch_cmd.add_argument("date", metavar="YYYYMMDD", help="trade date (basDd)")
77
+ fetch_cmd.add_argument("--market", default=None, metavar="M",
78
+ help="KOSPI/KOSDAQ/KONEX for stock; KOSPI/KOSDAQ for stock-derivatives")
79
+ fetch_cmd.add_argument("--json", action="store_true", help="emit JSON instead of text")
80
+ fetch_cmd.set_defaults(run=_run_fetch)
81
+
82
+ return parser
83
+
84
+
85
+ def _run_fetch(args: argparse.Namespace) -> int:
86
+ # Both usage checks run before KRX(), so a misused command is a usage error (exit 2)
87
+ # without needing an API key. The valid methods, and whether one takes --market,
88
+ # depend on the group -- more than argparse choices can express.
89
+ if args.method not in catalog.methods(args.group):
90
+ print(f"{_ERROR_PREFIX}unknown service {args.group} {args.method!r} "
91
+ f"(try `{_PROG} list {args.group}`)", file=sys.stderr)
92
+ return 2
93
+ if args.market is not None and not _accepts_market(args.group, args.method):
94
+ print(f"{_ERROR_PREFIX}fetch {args.group} {args.method} takes no --market",
95
+ file=sys.stderr)
96
+ return 2
97
+ method = getattr(getattr(KRX(), args.group), args.method)
98
+ kwargs = {"market": args.market} if args.market is not None else {}
99
+ try:
100
+ rows = method(args.date, **kwargs)
101
+ except ValueError as err:
102
+ # An unknown --market VALUE: the client raises ValueError (a caller mistake),
103
+ # a usage error (exit 2), not a traceback and not a transport failure.
104
+ print(f"{_ERROR_PREFIX}{err}", file=sys.stderr)
105
+ return 2
106
+ _emit(rows, args.json)
107
+ return 0
108
+
109
+
110
+ def _run_list(args: argparse.Namespace) -> int:
111
+ target = [args.group] if args.group else catalog.groups()
112
+ if args.json:
113
+ print(json.dumps({g: catalog.methods(g) for g in target}, ensure_ascii=False, indent=2))
114
+ return 0
115
+ lines = []
116
+ for group in target:
117
+ lines.append(group)
118
+ lines += [f" {group} {name}" for name in catalog.methods(group)]
119
+ print("\n".join(lines))
120
+ return 0
121
+
122
+
123
+ def _run_fields(args: argparse.Namespace) -> int:
124
+ try:
125
+ fields = catalog.fields(args.group, args.method)
126
+ except KeyError:
127
+ print(f"{_ERROR_PREFIX}unknown service {args.group} {args.method!r} "
128
+ f"(try `{_PROG} list {args.group}`)", file=sys.stderr)
129
+ return 2
130
+ if args.json:
131
+ print(json.dumps(fields, ensure_ascii=False, indent=2))
132
+ return 0
133
+ print("\n".join(fields))
134
+ return 0
135
+
136
+
137
+ def _emit(rows: Sequence[Row], as_json: bool) -> None:
138
+ if as_json:
139
+ print(json.dumps(list(rows), ensure_ascii=False, indent=2))
140
+ else:
141
+ print(_render_rows(rows))
142
+
143
+
144
+ def _render_rows(rows: Sequence[Row]) -> str:
145
+ """Rows as an aligned table over the first row's keys (up to ``_MAX_SHOWN_ROWS``),
146
+ then a total count. Empty -> ``(no rows)``."""
147
+ if not rows:
148
+ return "(no rows)"
149
+ headers = list(rows[0].keys())
150
+ shown = rows[:_MAX_SHOWN_ROWS]
151
+ body = [[row.get(key, "") for key in headers] for row in shown]
152
+ table = _render(headers, body)
153
+ if len(rows) > len(shown):
154
+ return f"{table}\n... ({len(rows)} rows total, showing {len(shown)})"
155
+ return f"{table}\n({len(rows)} rows)"
156
+
157
+
158
+ def _render(headers: Sequence[str], rows: Sequence[Sequence[str]]) -> str:
159
+ """Rows as an aligned table over ``headers``, one row per line."""
160
+ columns = list(zip(headers, *rows, strict=True)) if rows else [(h,) for h in headers]
161
+ widths = [max(len(str(cell)) for cell in column) for column in columns]
162
+ line = " ".join(str(h).ljust(w) for h, w in zip(headers, widths, strict=True))
163
+ body = "\n".join(
164
+ " ".join(str(cell).ljust(w) for cell, w in zip(row, widths, strict=True))
165
+ for row in rows)
166
+ return f"{line}\n{body}" if body else line
167
+
168
+
169
+ if __name__ == "__main__":
170
+ raise SystemExit(main())