instruction.md
# 한국은행 ECOS 경제통계 조회
## What this skill does
한국은행 경제통계시스템(ECOS) Open API `https://ecos.bok.or.kr/api/` 로 중앙은행 경제통계를 조회한다.
- `search` — 통계코드(또는 alias) 기반 시계열 데이터 조회
- `tables` — 통계표 카탈로그 목록
- `items` — 특정 통계표의 항목(item) 목록
- `key` — 100대 핵심지표 (환율/금리/물가/국민소득 등 최신값)
- `word` — 통계 용어 사전 검색
**조회 전용**이다. 투자 판단·전망 단정은 범위 밖이다.
## When to use
- "지금 한국은행 기준금리 몇 %야?"
- "최근 원달러 환율 추이 보여줘"
- "소비자물가지수 월별로 뽑아줘"
- "M2 통화량 통계 찾아줘"
- "ECOS에서 국고채 3년 금리 조회해줘"
## When not to use
- 주식/증권 시세 → `korean-stock-search`, `toss-investment`
- KOSIS 일반 통계(인구/가구/고용 등) → `kosis-stats`
- 환전 수수료·실시간 매매 환율 비교 (ECOS는 공식 통계 기준)
## Prerequisites
- Python 3.9+ (stdlib only, 외부 패키지 없음)
- 사용자 API 키 **불필요** — ECOS 공개 데모 키(`sample`)로 무가입 동작 확인 (2026-07-21). 단 **호출당 최대 10행** 제한이 있다. 공개 엔드포인트이므로 k-skill-proxy를 경유하지 않고 직접 호출한다.
선택 환경변수 (더 많은 행이 필요할 때):
- `KSKILL_BOK_ECOS_API_KEY` — https://ecos.bok.or.kr/api 회원가입 후 무료 발급. `~/.config/k-skill/secrets.env` 의 같은 키도 읽는다.
## Workflow
### 1. 자주 쓰는 지표는 alias로 바로 조회
```bash
npx -y @nomadamas/k-skill@0 exec bok-ecos-stats scripts/bok_ecos.py -- search --alias 기준금리 --start 20260101 --end 20260721 --text
npx -y @nomadamas/k-skill@0 exec bok-ecos-stats scripts/bok_ecos.py -- search --alias 원달러환율 --start 20260701 --end 20260721 --text
npx -y @nomadamas/k-skill@0 exec bok-ecos-stats scripts/bok_ecos.py -- search --alias 소비자물가지수 --start 202501 --end 202606 --text
```
지원 alias: `기준금리`, `원달러환율`, `소비자물가지수`(=`cpi`), `M2`(=`통화량`), `국고채3년`.
`--start`/`--end`는 주기 형식을 따른다: 일(D) `YYYYMMDD`, 월(M) `YYYYMM`, 분기(Q) `YYYYQn`, 연(A) `YYYY`.
### 2. 최신 핵심지표 한 번에 보기
```bash
npx -y @nomadamas/k-skill@0 exec bok-ecos-stats scripts/bok_ecos.py -- key --limit 10 --text
```
### 3. 임의 통계표 탐색 → 항목 확인 → 시계열 조회
```bash
npx -y @nomadamas/k-skill@0 exec bok-ecos-stats scripts/bok_ecos.py -- tables --text
npx -y @nomadamas/k-skill@0 exec bok-ecos-stats scripts/bok_ecos.py -- items --stat-code 722Y001 --text
npx -y @nomadamas/k-skill@0 exec bok-ecos-stats scripts/bok_ecos.py -- search --stat-code 722Y001 --cycle D \
--start 20260101 --end 20260721 --item-code 0101000 --text
```
### 4. 용어가 낯설면 사전 검색
```bash
npx -y @nomadamas/k-skill@0 exec bok-ecos-stats scripts/bok_ecos.py -- word --query 소비자물가지수 --text
```
`--text` 없이 실행하면 구조화 JSON(`result`, `rows`, `source`)을 출력한다.
## Data source
- `https://ecos.bok.or.kr/api/<Service>/<key>/json/kr/<start>/<end>/<segments...>` — 쿼리스트링 없는 positional URL. 한글 검색어는 helper가 percent-encoding 처리한다.
- 데모 키 `sample`: 무가입, 호출당 최대 10행 (초과 시 `ERROR-301`).
- 잘못된 키: HTTP 200 + `{"RESULT":{"CODE":"INFO-100"}}`.
- 빈 결과: `{"RESULT":{"CODE":"INFO-200","MESSAGE":"해당하는 데이터가 없습니다."}}` → `result: "empty"`.
## Failure modes
| 상황 | 동작 |
| --- | --- |
| 빈 결과 (`INFO-200`) | `result: "empty"` — 기간/코드 확인 안내 |
| 인증키 오류 (`INFO-100`) | 개인 키 발급/확인 안내 |
| sample 10행 초과 (`ERROR-301`) | 개인 키 발급 안내 (helper는 sample 사용 시 limit을 10으로 자동 캡) |
| 알 수 없는 alias, stat-code 없는 search | upstream 미호출, 사용법 오류 출력 |
| HTTP 오류/타임아웃/JSON 파싱 실패 | 차단·점검 가능성 안내 후 종료 코드 1 |
## Notes
- 시계열 값은 공식 통계 원천 그대로 전달하고, 해석·전망은 덧붙이지 않는다.
- 통계 기준 시점(`time`)을 반드시 함께 표시한다.
scripts/bok_ecos.py
#!/usr/bin/env python3
"""Read-only Bank of Korea ECOS economic statistics helper (stdlib only).
Site-dependent access path (discovered live 2026-07-21):
GET https://ecos.bok.or.kr/api/<Service>/<key>/json/kr/<start>/<end>/<segments...>
- Positional URL segments, no query string.
- The published demo key ``sample`` works without registration but caps each
call at 10 rows (exceeding it returns ERROR-301).
- Invalid keys return HTTP 200 with {"RESULT": {"CODE": "INFO-100", ...}}.
- Empty results return {"RESULT": {"CODE": "INFO-200", "MESSAGE": "해당하는 데이터가 없습니다."}}.
Services used:
- StatisticSearch/<stat>/<cycle>/<start>/<end>[/<item1>] — time series cells
- StatisticTableList — table catalog
- StatisticItemList/<stat> — items of one table
- KeyStatisticList — 100+ headline indicators (환율/금리/물가 등)
- StatisticWord/<keyword> — glossary
Because the demo key works without registration, this skill calls upstream
directly (no k-skill-proxy route). Users may set KSKILL_BOK_ECOS_API_KEY for
larger row limits.
"""
from __future__ import annotations
import argparse
import json
import os
import pathlib
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
from typing import Any, Dict, List, Optional
ECOS_BASE_URL = "https://ecos.bok.or.kr/api"
ECOS_DEMO_KEY = "sample"
SAMPLE_MAX_ROWS = 10
DEFAULT_SECRETS_PATH = pathlib.Path("~/.config/k-skill/secrets.env").expanduser()
USER_AGENT = "k-skill-bok-ecos/0.1 (+https://github.com/NomaDamas/k-skill)"
# 자주 쓰는 지표 alias → (stat_code, cycle, item_code)
ALIASES = {
"기준금리": ("722Y001", "D", "0101000"),
"원달러환율": ("731Y001", "D", "0000001"),
"원/달러환율": ("731Y001", "D", "0000001"),
"소비자물가지수": ("901Y009", "M", "0"),
"cpi": ("901Y009", "M", "0"),
"m2": ("101Y004", "M", "BBHA00"),
"통화량": ("101Y004", "M", "BBHA00"),
"국고채3년": ("817Y002", "D", "010200000"),
}
SEGMENT_PATTERN = re.compile(r"^[A-Za-z0-9_.\-]+$")
ERROR_HINTS = {
"INFO-100": "인증키가 유효하지 않습니다. KSKILL_BOK_ECOS_API_KEY를 확인하거나 https://ecos.bok.or.kr/api 에서 키를 발급받으세요.",
"ERROR-301": "sample 키는 최대 10건까지만 조회할 수 있습니다. 더 많은 행이 필요하면 개인 키를 발급받아 KSKILL_BOK_ECOS_API_KEY로 지정하세요.",
}
EMPTY_CODES = {"INFO-200"}
class HelperError(RuntimeError):
pass
def load_secrets(path: pathlib.Path) -> Dict[str, str]:
values: Dict[str, str] = {}
try:
lines = path.read_text(encoding="utf-8").splitlines()
except OSError:
return values
for raw in lines:
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
values[key.strip()] = value.strip().strip('"').strip("'")
return values
def resolve_api_key(secrets_path: str) -> Optional[str]:
env_value = os.environ.get("KSKILL_BOK_ECOS_API_KEY")
if env_value and env_value.strip():
return env_value.strip()
secrets = load_secrets(pathlib.Path(secrets_path).expanduser())
value = secrets.get("KSKILL_BOK_ECOS_API_KEY")
return value.strip() if value and value.strip() else None
def _check_segment(value: str, label: str) -> str:
if not SEGMENT_PATTERN.match(value or ""):
raise HelperError(f"올바른 {label} 값이 아닙니다: {value!r}")
return value
def _resolve_series(args: argparse.Namespace) -> tuple[str, str, Optional[str]]:
if args.alias:
needle = args.alias.strip().lower()
if needle not in ALIASES:
known = ", ".join(sorted(ALIASES))
raise HelperError(f"알 수 없는 alias 입니다: {args.alias!r} (지원: {known})")
stat_code, cycle, item_code = ALIASES[needle]
return stat_code, cycle, item_code
if not args.stat_code:
raise HelperError("--stat-code 또는 --alias 중 하나가 필요합니다.")
if not args.cycle:
raise HelperError("--stat-code 사용 시 --cycle(A/S/Q/M/SM/D)이 필요합니다.")
return (
_check_segment(args.stat_code, "stat-code"),
_check_segment(args.cycle, "cycle"),
_check_segment(args.item_code, "item-code") if args.item_code else None,
)
def build_url(args: argparse.Namespace, api_key: Optional[str]) -> str:
key = api_key or ECOS_DEMO_KEY
limit = max(1, args.limit)
if key == ECOS_DEMO_KEY:
limit = min(limit, SAMPLE_MAX_ROWS)
if args.command == "search":
stat_code, cycle, item_code = _resolve_series(args)
start = _check_segment(args.start, "start")
end = _check_segment(args.end, "end")
segments = [ECOS_BASE_URL, "StatisticSearch", key, "json", "kr", "1", str(limit), stat_code, cycle, start, end]
if item_code:
segments.append(item_code)
return "/".join(segments)
if args.command == "tables":
return "/".join([ECOS_BASE_URL, "StatisticTableList", key, "json", "kr", "1", str(limit)]) + "/"
if args.command == "items":
stat_code = _check_segment(args.stat_code, "stat-code")
return "/".join([ECOS_BASE_URL, "StatisticItemList", key, "json", "kr", "1", str(limit), stat_code])
if args.command == "key":
return "/".join([ECOS_BASE_URL, "KeyStatisticList", key, "json", "kr", "1", str(limit)]) + "/"
# word
query = (args.query or "").strip()
if not query:
raise HelperError("--query 검색어가 필요합니다.")
encoded = urllib.parse.quote(query, safe="")
return "/".join([ECOS_BASE_URL, "StatisticWord", key, "json", "kr", "1", str(limit), encoded])
def http_get_json(url: str, timeout: int) -> Dict[str, Any]:
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
body = response.read().decode("utf-8", "replace")
except urllib.error.HTTPError as exc:
raise HelperError(f"ECOS HTTP 오류: {exc.code}") from exc
except urllib.error.URLError as exc:
raise HelperError(f"ECOS 접속 실패: {exc.reason}") from exc
try:
return json.loads(body)
except json.JSONDecodeError as exc:
raise HelperError("ECOS 응답이 JSON이 아닙니다 (차단 또는 점검 가능성).") from exc
def normalize_payload(payload: Dict[str, Any], service: str) -> List[Dict[str, Any]]:
if not isinstance(payload, dict):
raise HelperError("ECOS 응답 형식이 올바르지 않습니다.")
result = payload.get("RESULT")
if isinstance(result, dict):
code = str(result.get("CODE") or "")
if code in EMPTY_CODES:
return []
message = ERROR_HINTS.get(code) or f"ECOS 오류 [{code}]: {result.get('MESSAGE', '')}"
raise HelperError(message)
body = payload.get(service)
if not isinstance(body, dict) or not isinstance(body.get("row"), list):
raise HelperError("ECOS 응답에서 데이터 행을 찾지 못했습니다 (서비스명/파라미터 확인).")
return [row for row in body["row"] if isinstance(row, dict)]
def _project(rows: List[Dict[str, Any]], mapping: Dict[str, str]) -> List[Dict[str, Any]]:
return [{out: row.get(src) for out, src in mapping.items()} for row in rows]
PROJECTIONS = {
"search": {
"stat_code": "STAT_CODE",
"stat_name": "STAT_NAME",
"item_name": "ITEM_NAME1",
"unit": "UNIT_NAME",
"time": "TIME",
"value": "DATA_VALUE",
},
"tables": {
"stat_code": "STAT_CODE",
"stat_name": "STAT_NAME",
"cycle": "CYCLE",
"searchable": "SRCH_YN",
"org": "ORG_NAME",
},
"items": {
"stat_code": "STAT_CODE",
"item_code": "ITEM_CODE",
"item_name": "ITEM_NAME",
"cycle": "CYCLE",
"start_time": "START_TIME",
"end_time": "END_TIME",
},
"key": {
"class_name": "CLASS_NAME",
"name": "KEYSTAT_NAME",
"value": "DATA_VALUE",
"cycle": "CYCLE",
"unit": "UNIT_NAME",
},
"word": {
"word": "WORD",
"content": "CONTENT",
},
}
SERVICES = {
"search": "StatisticSearch",
"tables": "StatisticTableList",
"items": "StatisticItemList",
"key": "KeyStatisticList",
"word": "StatisticWord",
}
def render_text(command: str, rows: List[Dict[str, Any]]) -> str:
if not rows:
return "조건에 맞는 데이터가 없습니다."
lines = []
for row in rows:
if command == "search":
lines.append(f"{row['time']} · {row['item_name']}: {row['value']} {row['unit'] or ''}".rstrip())
elif command == "key":
lines.append(f"[{row['class_name']}] {row['name']}: {row['value']} {row['unit'] or ''} ({row['cycle']})")
elif command == "tables":
lines.append(f"{row['stat_code']} · {row['stat_name']} (주기 {row['cycle'] or '-'})")
elif command == "items":
lines.append(f"{row['item_code']} · {row['item_name']} ({row['cycle']}, {row['start_time']}~{row['end_time']})")
else:
lines.append(f"{row['word']}: {row['content']}")
return "\n".join(lines)
def parse_args(argv: List[str]) -> argparse.Namespace:
common = argparse.ArgumentParser(add_help=False)
common.add_argument("--secrets-path", default=str(DEFAULT_SECRETS_PATH))
common.add_argument("--timeout", type=int, default=30)
common.add_argument("--limit", type=int, default=10, help="조회 행 수 (sample 키는 최대 10)")
common.add_argument("--text", action="store_true", help="사람용 요약 출력")
parser = argparse.ArgumentParser(description="한국은행 ECOS 경제통계 조회")
subparsers = parser.add_subparsers(dest="command", required=True)
search = subparsers.add_parser("search", parents=[common], help="통계코드/alias 기반 시계열 조회")
search.add_argument("--stat-code", help="통계표 코드 (예: 722Y001)")
search.add_argument("--cycle", help="주기: A/S/Q/M/SM/D")
search.add_argument("--item-code", help="통계항목 코드 (선택)")
search.add_argument("--alias", help="자주 쓰는 지표 별칭 (기준금리/원달러환율/소비자물가지수/M2/국고채3년)")
search.add_argument("--start", required=True, help="시작 시점 (주기 형식에 맞춤, 예: 20260101, 202601, 2026)")
search.add_argument("--end", required=True, help="종료 시점")
subparsers.add_parser("tables", parents=[common], help="통계표 목록")
items = subparsers.add_parser("items", parents=[common], help="통계표의 항목 목록")
items.add_argument("--stat-code", required=True)
subparsers.add_parser("key", parents=[common], help="100대 핵심 지표 (환율/금리/물가 등)")
word = subparsers.add_parser("word", parents=[common], help="통계 용어 사전 검색")
word.add_argument("--query", required=True)
return parser.parse_args(argv)
def run(argv: List[str]) -> int:
args = parse_args(argv)
try:
api_key = resolve_api_key(args.secrets_path)
url = build_url(args, api_key)
payload = http_get_json(url, args.timeout)
raw_rows = normalize_payload(payload, SERVICES[args.command])
rows = _project(raw_rows, PROJECTIONS[args.command])
if args.text:
print(render_text(args.command, rows))
else:
print(json.dumps({
"result": "ok" if rows else "empty",
"rows": rows,
"source": "ecos.bok.or.kr Open API",
}, ensure_ascii=False, indent=2))
return 0
except HelperError as exc:
print(str(exc), file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(run(sys.argv[1:]))
skill.json
{
"name": "bok-ecos-stats",
"description": "한국은행 ECOS Open API로 기준금리, 환율, 소비자물가지수, 통화량 등 중앙은행 경제통계 시계열과 100대 핵심지표를 조회한다. Use when the user asks 기준금리, 환율 추이, 물가지수, M2, 국고채 금리, 거시경제 통계. Not for 주식 시세(korean-stock-search) or KOSIS 일반 통계(kosis-stats).",
"profiles": [
"proxy",
"lookup"
],
"frontmatter": "name: bok-ecos-stats\ndescription: 한국은행 ECOS Open API로 기준금리, 환율, 소비자물가지수, 통화량 등 중앙은행 경제통계 시계열과 100대 핵심지표를 조회한다. Use when the user asks 기준금리, 환율 추이, 물가지수, M2, 국고채 금리, 거시경제 통계. Not for 주식 시세(korean-stock-search) or KOSIS 일반 통계(kosis-stats).\nlicense: MIT\nmetadata:\n category: data\n locale: ko-KR\n phase: v1"
}
SKILL.md
---
name: bok-ecos-stats
description: 한국은행 ECOS Open API로 기준금리, 환율, 소비자물가지수, 통화량 등 중앙은행 경제통계 시계열과 100대 핵심지표를 조회한다. Use when the user asks 기준금리, 환율 추이, 물가지수, M2, 국고채 금리, 거시경제 통계. Not for 주식 시세(korean-stock-search) or KOSIS 일반 통계(kosis-stats).
license: MIT
metadata:
category: data
locale: ko-KR
phase: v1
---
# bok-ecos-stats
<!-- k-skill:cli-stub — generated by scripts/generate-skill-stubs.js; edit skill.json / instruction.md instead -->
## Get the full instructions (required first step)
Run this and follow its output as the primary instructions for this skill:
```bash
npx -y @nomadamas/k-skill@0 instruct bok-ecos-stats
```
The CLI detects the current runtime (Dolshoi vault/CloakBrowser vs generic) and prints only the applicable instructions, always up to date. Helper files bundled with the CLI are listed by:
```bash
npx -y @nomadamas/k-skill@0 files bok-ecos-stats
```
If `npx` is unavailable, install Node.js 18+ or follow https://github.com/NomaDamas/k-skill#readme, or read the source instructions at https://github.com/NomaDamas/k-skill/blob/main/bok-ecos-stats/instruction.md.
## Hard rules even without the CLI
- Never execute payment, message/email delivery, final submission, cancellation, or public posting without the user's explicit approval immediately beforehand.
- Never ask for, print, or store plaintext credentials in chat, files, or shell arguments.
- Never bypass legal, physical-presence, CAPTCHA, identity-proofing, or electronic-signature boundaries.
tests/test_bok_ecos.py
import contextlib
import importlib.util
import io
import json
import pathlib
import unittest
import urllib.parse
from unittest import mock
ROOT = pathlib.Path(__file__).resolve().parents[2]
MODULE_PATH = ROOT / "bok-ecos-stats" / "scripts" / "bok_ecos.py"
SPEC = importlib.util.spec_from_file_location("bok_ecos", MODULE_PATH)
bok_ecos = importlib.util.module_from_spec(SPEC)
assert SPEC.loader is not None
SPEC.loader.exec_module(bok_ecos)
SEARCH_PAYLOAD = {
"StatisticSearch": {
"list_total_count": 2,
"row": [
{
"STAT_CODE": "722Y001",
"STAT_NAME": "1.3.1. 한국은행 기준금리 및 여수신금리",
"ITEM_CODE1": "0101000",
"ITEM_NAME1": "한국은행 기준금리",
"UNIT_NAME": "연%",
"TIME": "20260105",
"DATA_VALUE": "2.5",
},
{
"STAT_CODE": "722Y001",
"STAT_NAME": "1.3.1. 한국은행 기준금리 및 여수신금리",
"ITEM_CODE1": "0101000",
"ITEM_NAME1": "한국은행 기준금리",
"UNIT_NAME": "연%",
"TIME": "20260106",
"DATA_VALUE": "2.5",
},
],
}
}
KEYSTAT_PAYLOAD = {
"KeyStatisticList": {
"list_total_count": 101,
"row": [
{
"CLASS_NAME": "환율",
"KEYSTAT_NAME": "원/달러 환율(종가)",
"DATA_VALUE": "1473.4",
"CYCLE": "20260721",
"UNIT_NAME": "원",
}
],
}
}
TABLES_PAYLOAD = {
"StatisticTableList": {
"list_total_count": 1,
"row": [
{
"P_STAT_CODE": "0000000001",
"STAT_CODE": "722Y001",
"STAT_NAME": "1.3.1. 한국은행 기준금리 및 여수신금리",
"CYCLE": "D",
"SRCH_YN": "Y",
"ORG_NAME": "한국은행",
}
],
}
}
ITEMS_PAYLOAD = {
"StatisticItemList": {
"list_total_count": 1,
"row": [
{
"STAT_CODE": "722Y001",
"GRP_NAME": "계정항목",
"ITEM_CODE": "0101000",
"ITEM_NAME": "한국은행 기준금리",
"CYCLE": "D",
"START_TIME": "19990506",
"END_TIME": "20260721",
}
],
}
}
WORD_PAYLOAD = {
"StatisticWord": {
"list_total_count": 1,
"row": [{"WORD": "소비자물가지수", "CONTENT": "물가 변동을 종합적으로 파악하기 위한 지수."}],
}
}
BAD_KEY_PAYLOAD = {
"RESULT": {"CODE": "INFO-100", "MESSAGE": "인증키가 유효하지 않습니다. 인증키를 확인하십시오!"}
}
EMPTY_PAYLOAD = {"RESULT": {"CODE": "INFO-200", "MESSAGE": "해당하는 데이터가 없습니다."}}
SAMPLE_LIMIT_PAYLOAD = {
"RESULT": {"CODE": "ERROR-301", "MESSAGE": "sample은 최대 10건 이내에서 호출이 가능합니다."}
}
class UrlBuildTests(unittest.TestCase):
def test_search_url_uses_sample_key_and_positional_segments(self):
args = bok_ecos.parse_args([
"search", "--stat-code", "722Y001", "--cycle", "D",
"--start", "20260101", "--end", "20260110", "--item-code", "0101000",
])
url = bok_ecos.build_url(args, api_key=None)
self.assertIn("/StatisticSearch/sample/json/kr/1/10/722Y001/D/20260101/20260110/0101000", url)
def test_search_url_uses_user_key_and_limit(self):
args = bok_ecos.parse_args([
"search", "--stat-code", "722Y001", "--cycle", "D",
"--start", "20260101", "--end", "20260110", "--limit", "500",
])
url = bok_ecos.build_url(args, api_key="MYKEY")
self.assertIn("/StatisticSearch/MYKEY/json/kr/1/500/722Y001/D/20260101/20260110", url)
def test_sample_key_caps_limit_to_ten(self):
args = bok_ecos.parse_args([
"search", "--stat-code", "722Y001", "--cycle", "D",
"--start", "20260101", "--end", "20260110", "--limit", "500",
])
url = bok_ecos.build_url(args, api_key=None)
self.assertIn("/kr/1/10/", url)
def test_alias_resolves_to_stat_code_cycle_item(self):
args = bok_ecos.parse_args(["search", "--alias", "기준금리", "--start", "20260101", "--end", "20260110"])
url = bok_ecos.build_url(args, api_key=None)
self.assertIn("722Y001/D/20260101/20260110/0101000", url)
def test_unknown_alias_raises(self):
args = bok_ecos.parse_args(["search", "--alias", "없는지표", "--start", "2026", "--end", "2026"])
with self.assertRaisesRegex(bok_ecos.HelperError, "alias"):
bok_ecos.build_url(args, api_key=None)
def test_search_requires_stat_code_or_alias(self):
args = bok_ecos.parse_args(["search", "--start", "2026", "--end", "2026"])
with self.assertRaisesRegex(bok_ecos.HelperError, "stat-code"):
bok_ecos.build_url(args, api_key=None)
def test_word_url_percent_encodes_korean(self):
args = bok_ecos.parse_args(["word", "--query", "소비자물가지수"])
url = bok_ecos.build_url(args, api_key=None)
self.assertNotIn("소비자물가지수", url)
self.assertIn(urllib.parse.quote("소비자물가지수"), url)
def test_invalid_segment_characters_rejected(self):
args = bok_ecos.parse_args([
"search", "--stat-code", "722Y001/evil", "--cycle", "D",
"--start", "20260101", "--end", "20260110",
])
with self.assertRaisesRegex(bok_ecos.HelperError, "stat-code"):
bok_ecos.build_url(args, api_key=None)
class NormalizeTests(unittest.TestCase):
def test_normalize_search_rows(self):
rows = bok_ecos.normalize_payload(SEARCH_PAYLOAD, "StatisticSearch")
self.assertEqual(len(rows), 2)
self.assertEqual(rows[0]["DATA_VALUE"], "2.5")
def test_normalize_raises_typed_error_on_bad_key(self):
with self.assertRaises(bok_ecos.HelperError) as ctx:
bok_ecos.normalize_payload(BAD_KEY_PAYLOAD, "StatisticSearch")
self.assertIn("인증키", str(ctx.exception))
def test_normalize_returns_empty_list_for_no_data(self):
rows = bok_ecos.normalize_payload(EMPTY_PAYLOAD, "StatisticSearch")
self.assertEqual(rows, [])
def test_normalize_raises_on_sample_limit_error(self):
with self.assertRaises(bok_ecos.HelperError) as ctx:
bok_ecos.normalize_payload(SAMPLE_LIMIT_PAYLOAD, "StatisticSearch")
self.assertIn("10건", str(ctx.exception))
def test_normalize_raises_on_unexpected_shape(self):
with self.assertRaises(bok_ecos.HelperError):
bok_ecos.normalize_payload({"weird": True}, "StatisticSearch")
class RunTests(unittest.TestCase):
def _run(self, argv, payload):
stdout = io.StringIO()
with mock.patch.object(bok_ecos, "http_get_json", return_value=payload), \
contextlib.redirect_stdout(stdout):
code = bok_ecos.run(argv)
return code, stdout.getvalue()
def test_run_search_outputs_series_json(self):
code, out = self._run(
["search", "--alias", "기준금리", "--start", "20260101", "--end", "20260110"],
SEARCH_PAYLOAD,
)
self.assertEqual(code, 0)
payload = json.loads(out)
self.assertEqual(payload["result"], "ok")
self.assertEqual(len(payload["rows"]), 2)
self.assertEqual(payload["rows"][0]["value"], "2.5")
self.assertIn("ecos.bok.or.kr", payload["source"])
def test_run_search_empty_is_explicit(self):
code, out = self._run(
["search", "--stat-code", "722Y001", "--cycle", "D", "--start", "19000101", "--end", "19000102"],
EMPTY_PAYLOAD,
)
self.assertEqual(code, 0)
payload = json.loads(out)
self.assertEqual(payload["result"], "empty")
self.assertEqual(payload["rows"], [])
def test_run_key_outputs_headline_stats(self):
code, out = self._run(["key"], KEYSTAT_PAYLOAD)
self.assertEqual(code, 0)
payload = json.loads(out)
self.assertEqual(payload["rows"][0]["name"], "원/달러 환율(종가)")
def test_run_tables_and_items_and_word(self):
code, out = self._run(["tables"], TABLES_PAYLOAD)
self.assertEqual(code, 0)
self.assertEqual(json.loads(out)["rows"][0]["stat_code"], "722Y001")
code, out = self._run(["items", "--stat-code", "722Y001"], ITEMS_PAYLOAD)
self.assertEqual(code, 0)
self.assertEqual(json.loads(out)["rows"][0]["item_code"], "0101000")
code, out = self._run(["word", "--query", "소비자물가지수"], WORD_PAYLOAD)
self.assertEqual(code, 0)
self.assertIn("지수", json.loads(out)["rows"][0]["content"])
def test_run_reports_key_error_to_stderr(self):
stderr = io.StringIO()
with mock.patch.object(bok_ecos, "http_get_json", return_value=BAD_KEY_PAYLOAD), \
contextlib.redirect_stderr(stderr):
code = bok_ecos.run(["search", "--alias", "기준금리", "--start", "2026", "--end", "2026"])
self.assertEqual(code, 1)
self.assertIn("인증키", stderr.getvalue())
def test_run_text_mode_renders_series(self):
stdout = io.StringIO()
with mock.patch.object(bok_ecos, "http_get_json", return_value=SEARCH_PAYLOAD), \
contextlib.redirect_stdout(stdout):
code = bok_ecos.run(["search", "--alias", "기준금리", "--start", "20260101", "--end", "20260110", "--text"])
self.assertEqual(code, 0)
self.assertIn("한국은행 기준금리", stdout.getvalue())
self.assertIn("2.5", stdout.getvalue())
if __name__ == "__main__":
unittest.main()