ecos-reader 0.2.0__tar.gz → 0.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/CHANGELOG.md +15 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/PKG-INFO +1 -1
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/pyproject.toml +1 -1
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/src/ecos/__init__.py +5 -1
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/src/ecos/cache.py +48 -41
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/src/ecos/client.py +81 -38
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/src/ecos/config.py +6 -0
- ecos_reader-0.2.1/src/ecos/exceptions.py +102 -0
- ecos_reader-0.2.1/src/ecos/indicators/_dates.py +64 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/src/ecos/indicators/bond.py +2 -20
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/src/ecos/indicators/fiscal.py +2 -21
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/src/ecos/indicators/growth.py +13 -33
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/src/ecos/indicators/interest_rate.py +31 -30
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/src/ecos/indicators/money.py +13 -40
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/src/ecos/indicators/prices.py +6 -24
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/src/ecos/indicators/stock.py +4 -33
- ecos_reader-0.2.1/tests/indicators/test_dates.py +99 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/tests/indicators/test_interest_rate.py +58 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/tests/test_cache.py +46 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/tests/test_client.py +96 -0
- ecos_reader-0.2.1/tests/test_exceptions.py +131 -0
- ecos_reader-0.2.0/src/ecos/exceptions.py +0 -73
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/.github/workflows/ci.yml +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/.github/workflows/docs.yml +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/.github/workflows/publish.yml +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/.gitignore +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/.pre-commit-config.yaml +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/API_SPEC.md +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/CONTRIBUTING.md +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/IMPLEMENTATION_STATUS.md +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/LICENSE +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/README.md +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/docs/api-reference/client.md +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/docs/api-reference/exceptions.md +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/docs/api-reference/indicators.md +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/docs/api-reference/overview.md +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/docs/api-reference/statistic-item-list.md +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/docs/api-reference/statistic-meta.md +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/docs/api-reference/statistic-search.md +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/docs/api-reference/statistic-table-list.md +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/docs/api-reference/statistic-top-100.md +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/docs/api-reference/statistic-word.md +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/docs/development/contributing.md +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/docs/development/release.md +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/docs/examples/basic.md +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/docs/examples/dashboard.md +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/docs/getting-started/installation.md +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/docs/getting-started/quickstart.md +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/docs/index.md +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/docs/user-guide/advanced.md +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/docs/user-guide/basic-usage.md +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/docs/user-guide/financial-markets.md +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/docs/user-guide/fiscal.md +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/docs/user-guide/growth.md +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/docs/user-guide/interest-rates.md +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/docs/user-guide/money.md +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/docs/user-guide/prices.md +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/ecos_all_statistics.csv +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/ecos_implementation_status.csv +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/examples/basic_usage.py +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/examples/macro_dashboard.py +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/mkdocs.yml +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/public-api-docs/statistic-item-list.md +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/public-api-docs/statistic-meta.md +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/public-api-docs/statistic-search.md +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/public-api-docs/statistic-table-list.md +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/public-api-docs/statistic-top-100.md +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/public-api-docs/statistic-word.md +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/src/ecos/constants.py +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/src/ecos/indicators/__init__.py +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/src/ecos/indicators/_deprecations.py +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/src/ecos/logging.py +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/src/ecos/metrics.py +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/src/ecos/parser.py +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/src/ecos/py.typed +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/src/ecos/types.py +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/tests/__init__.py +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/tests/conftest.py +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/tests/indicators/__init__.py +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/tests/indicators/test_deprecations.py +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/tests/indicators/test_growth.py +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/tests/indicators/test_money.py +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/tests/indicators/test_prices.py +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/tests/test_config.py +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/tests/test_e2e.py +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/tests/test_e2e_indicators.py +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/tests/test_parser.py +0 -0
- {ecos_reader-0.2.0 → ecos_reader-0.2.1}/uv.lock +0 -0
|
@@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.2.1] - 2026-05-29
|
|
9
|
+
|
|
10
|
+
> 0.3.0 은 시그니처 breaking 재설계(epic #3)를 위해 예약돼 있어, 본 하위호환
|
|
11
|
+
> 기능 추가는 patch(0.2.1) 로 릴리스합니다.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- `get_base_rate` 에 `frequency` 파라미터 추가 (`"M"` 기본 / `"D"`). ECOS 통계표
|
|
15
|
+
`722Y001` 은 일별 원천이라 `frequency="D"` 로 변경일 단위(sparse) 시계열을
|
|
16
|
+
조회할 수 있습니다. `"D"` 지정 시 날짜 형식은 `YYYYMMDD`, 기본 조회기간도
|
|
17
|
+
일별 포맷으로 산출합니다. 기본값 `"M"` 으로 기존 동작은 그대로 유지(하위호환).
|
|
18
|
+
|
|
19
|
+
### Notes
|
|
20
|
+
- M2 평잔·계절조정(`161Y005`/`BBHS00`) 시계열은 기존
|
|
21
|
+
`get_m2_variants(variant="평잔_계절조정")` 로 이미 제공됩니다 (별도 추가 없음).
|
|
22
|
+
|
|
8
23
|
## [0.2.0] - 2026-05-29
|
|
9
24
|
|
|
10
25
|
v0.1.6 라이브 e2e 검증에서 드러난 follow-up(#2 Reliability epic)을 정리한 릴리스.
|
|
@@ -20,7 +20,7 @@ Examples
|
|
|
20
20
|
|
|
21
21
|
from __future__ import annotations
|
|
22
22
|
|
|
23
|
-
__version__ = "0.2.
|
|
23
|
+
__version__ = "0.2.1"
|
|
24
24
|
__author__ = "yeonguk"
|
|
25
25
|
|
|
26
26
|
# Config API
|
|
@@ -38,6 +38,8 @@ from .exceptions import (
|
|
|
38
38
|
EcosError,
|
|
39
39
|
EcosNetworkError,
|
|
40
40
|
EcosRateLimitError,
|
|
41
|
+
EcosServerError,
|
|
42
|
+
EcosValidationError,
|
|
41
43
|
)
|
|
42
44
|
|
|
43
45
|
# Indicator APIs
|
|
@@ -111,6 +113,8 @@ __all__ = [
|
|
|
111
113
|
"EcosConfigError",
|
|
112
114
|
"EcosNetworkError",
|
|
113
115
|
"EcosRateLimitError",
|
|
116
|
+
"EcosServerError",
|
|
117
|
+
"EcosValidationError",
|
|
114
118
|
# Warnings
|
|
115
119
|
"EcosPartialCoverageWarning",
|
|
116
120
|
# 재정 지표
|
|
@@ -8,7 +8,9 @@ from __future__ import annotations
|
|
|
8
8
|
|
|
9
9
|
import hashlib
|
|
10
10
|
import json
|
|
11
|
+
import threading
|
|
11
12
|
import time
|
|
13
|
+
from collections import OrderedDict
|
|
12
14
|
from dataclasses import dataclass
|
|
13
15
|
from typing import Any
|
|
14
16
|
|
|
@@ -53,8 +55,11 @@ class Cache:
|
|
|
53
55
|
def __init__(self, ttl: int = 3600, maxsize: int = 100):
|
|
54
56
|
self.ttl = ttl
|
|
55
57
|
self.maxsize = maxsize
|
|
56
|
-
|
|
57
|
-
|
|
58
|
+
# OrderedDict가 LRU 순서를 직접 보유하므로 O(1) move_to_end/popitem 사용.
|
|
59
|
+
# 별도 _access_order 리스트(O(n) remove)와의 비일관성을 제거한다.
|
|
60
|
+
self._cache: OrderedDict[str, CacheEntry] = OrderedDict()
|
|
61
|
+
# 재진입 가능 락: get()이 만료 시 invalidate()를 호출하므로 RLock 필요.
|
|
62
|
+
self._lock = threading.RLock()
|
|
58
63
|
self._enabled: bool = True
|
|
59
64
|
|
|
60
65
|
@property
|
|
@@ -103,26 +108,26 @@ class Cache:
|
|
|
103
108
|
if not self._enabled:
|
|
104
109
|
return None
|
|
105
110
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
+
with self._lock:
|
|
112
|
+
entry = self._cache.get(key)
|
|
113
|
+
if entry is None:
|
|
114
|
+
record_cache_miss()
|
|
115
|
+
log_cache_operation("get", key, hit=False)
|
|
116
|
+
return None
|
|
111
117
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
118
|
+
if entry.is_expired():
|
|
119
|
+
# 만료 항목 능동 제거 (maxsize 미만이어도 메모리 점유 방지)
|
|
120
|
+
self._cache.pop(key, None)
|
|
121
|
+
record_cache_miss()
|
|
122
|
+
log_cache_operation("get", key, hit=False)
|
|
123
|
+
return None
|
|
117
124
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
self._access_order.remove(key)
|
|
121
|
-
self._access_order.append(key)
|
|
125
|
+
# LRU: 접근된 항목을 최신으로 이동 (O(1))
|
|
126
|
+
self._cache.move_to_end(key)
|
|
122
127
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
128
|
+
record_cache_hit()
|
|
129
|
+
log_cache_operation("get", key, hit=True)
|
|
130
|
+
return entry.value
|
|
126
131
|
|
|
127
132
|
def set(self, key: str, value: Any) -> None:
|
|
128
133
|
"""
|
|
@@ -138,19 +143,19 @@ class Cache:
|
|
|
138
143
|
if not self._enabled:
|
|
139
144
|
return
|
|
140
145
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
146
|
+
with self._lock:
|
|
147
|
+
if key in self._cache:
|
|
148
|
+
# 기존 키 갱신: 값 교체 후 최신으로 이동, 퇴출 불필요
|
|
149
|
+
self._cache[key] = CacheEntry(value=value, created_at=time.time(), ttl=self.ttl)
|
|
150
|
+
self._cache.move_to_end(key)
|
|
151
|
+
else:
|
|
152
|
+
# 최대 크기 초과 시 가장 오래된 항목(맨 앞) 제거 (O(1))
|
|
153
|
+
while self._cache and len(self._cache) >= self.maxsize:
|
|
154
|
+
self._cache.popitem(last=False)
|
|
155
|
+
self._cache[key] = CacheEntry(value=value, created_at=time.time(), ttl=self.ttl)
|
|
151
156
|
|
|
152
|
-
|
|
153
|
-
|
|
157
|
+
record_cache_set()
|
|
158
|
+
log_cache_operation("set", key)
|
|
154
159
|
|
|
155
160
|
def invalidate(self, key: str) -> None:
|
|
156
161
|
"""
|
|
@@ -161,24 +166,26 @@ class Cache:
|
|
|
161
166
|
key : str
|
|
162
167
|
무효화할 캐시 키
|
|
163
168
|
"""
|
|
164
|
-
self.
|
|
165
|
-
|
|
166
|
-
self._access_order.remove(key)
|
|
169
|
+
with self._lock:
|
|
170
|
+
self._cache.pop(key, None)
|
|
167
171
|
|
|
168
172
|
def clear(self) -> None:
|
|
169
173
|
"""모든 캐시를 삭제합니다."""
|
|
170
|
-
self.
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
+
with self._lock:
|
|
175
|
+
self._cache.clear()
|
|
176
|
+
record_cache_clear()
|
|
177
|
+
log_cache_operation("clear", "")
|
|
174
178
|
|
|
175
179
|
def __len__(self) -> int:
|
|
176
180
|
"""캐시된 항목 수"""
|
|
177
|
-
|
|
181
|
+
with self._lock:
|
|
182
|
+
return len(self._cache)
|
|
178
183
|
|
|
179
184
|
def __contains__(self, key: str) -> bool:
|
|
180
|
-
"""캐시 키 존재 여부"""
|
|
181
|
-
|
|
185
|
+
"""캐시 키 존재 여부 (만료 항목은 미포함으로 간주)"""
|
|
186
|
+
with self._lock:
|
|
187
|
+
entry = self._cache.get(key)
|
|
188
|
+
return entry is not None and not entry.is_expired()
|
|
182
189
|
|
|
183
190
|
|
|
184
191
|
# 전역 캐시 인스턴스
|
|
@@ -12,13 +12,15 @@ from typing import Any, cast
|
|
|
12
12
|
from urllib.parse import quote
|
|
13
13
|
|
|
14
14
|
import requests
|
|
15
|
+
from requests.adapters import HTTPAdapter
|
|
16
|
+
from urllib3.util import Retry
|
|
15
17
|
|
|
16
18
|
from .cache import Cache, get_cache
|
|
17
19
|
from .config import Settings, get_api_key
|
|
18
20
|
from .exceptions import (
|
|
21
|
+
ECOS_ERROR_CODES,
|
|
19
22
|
RETRYABLE_ERROR_CODES,
|
|
20
23
|
EcosAPIError,
|
|
21
|
-
EcosConfigError,
|
|
22
24
|
EcosNetworkError,
|
|
23
25
|
EcosRateLimitError,
|
|
24
26
|
)
|
|
@@ -68,9 +70,40 @@ class EcosClient:
|
|
|
68
70
|
self.timeout = timeout
|
|
69
71
|
self.max_retries = max_retries
|
|
70
72
|
self.use_cache = use_cache
|
|
71
|
-
self.session =
|
|
73
|
+
self.session = self._build_session()
|
|
72
74
|
self._cache: Cache | None = get_cache() if use_cache else None
|
|
73
75
|
|
|
76
|
+
def _build_session(self) -> requests.Session:
|
|
77
|
+
"""재시도 정책과 커넥션 풀이 구성된 Session을 생성합니다.
|
|
78
|
+
|
|
79
|
+
전송 계층(urllib3.Retry)에서 처리하는 것:
|
|
80
|
+
- 5xx/429/408 및 연결/읽기 오류 재시도 (``status_forcelist``)
|
|
81
|
+
- 4xx(408/429 제외)는 ``status_forcelist`` 밖이므로 재시도하지 않음
|
|
82
|
+
- 429/503의 ``Retry-After`` 헤더 존중 (``respect_retry_after_header``)
|
|
83
|
+
|
|
84
|
+
ECOS 비즈니스 에러(HTTP 200 + ``RESULT.CODE``)는 이 계층에서 보이지
|
|
85
|
+
않으므로 ``_make_request``의 애플리케이션 루프에서 별도로 재시도한다.
|
|
86
|
+
"""
|
|
87
|
+
session = requests.Session()
|
|
88
|
+
retry = Retry(
|
|
89
|
+
total=self.max_retries,
|
|
90
|
+
backoff_factor=Settings.RETRY_BACKOFF_FACTOR,
|
|
91
|
+
status_forcelist=Settings.RETRY_STATUS_FORCELIST,
|
|
92
|
+
allowed_methods=frozenset({"GET"}),
|
|
93
|
+
respect_retry_after_header=True,
|
|
94
|
+
# 재시도 소진 후에도 마지막 응답을 그대로 반환받아 raise_for_status로
|
|
95
|
+
# 일관되게 변환한다 (urllib3가 MaxRetryError를 던지지 않도록).
|
|
96
|
+
raise_on_status=False,
|
|
97
|
+
)
|
|
98
|
+
adapter = HTTPAdapter(
|
|
99
|
+
max_retries=retry,
|
|
100
|
+
pool_connections=Settings.POOL_CONNECTIONS,
|
|
101
|
+
pool_maxsize=Settings.POOL_MAXSIZE,
|
|
102
|
+
)
|
|
103
|
+
session.mount("https://", adapter)
|
|
104
|
+
session.mount("http://", adapter)
|
|
105
|
+
return session
|
|
106
|
+
|
|
74
107
|
def _get_api_key(self) -> str:
|
|
75
108
|
"""
|
|
76
109
|
요청 시점의 API Key를 반환합니다.
|
|
@@ -155,6 +188,9 @@ class EcosClient:
|
|
|
155
188
|
EcosRateLimitError
|
|
156
189
|
Rate Limit 초과 시
|
|
157
190
|
"""
|
|
191
|
+
# 전송 계층(urllib3.Retry)이 5xx/429/408·연결/읽기 오류를 이미 재시도한다.
|
|
192
|
+
# 이 루프는 HTTP 200으로 내려오는 ECOS 비즈니스 에러(ERROR-500/600/602 등
|
|
193
|
+
# RETRYABLE_ERROR_CODES)만 추가로 재시도한다.
|
|
158
194
|
last_exception: Exception | None = None
|
|
159
195
|
|
|
160
196
|
for attempt in range(self.max_retries):
|
|
@@ -170,38 +206,35 @@ class EcosClient:
|
|
|
170
206
|
logger.debug(f"API 응답 성공: {len(data)} 바이트 수신")
|
|
171
207
|
return data
|
|
172
208
|
|
|
173
|
-
except requests.exceptions.Timeout:
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
log_retry_attempt(attempt + 1, self.max_retries, last_exception)
|
|
209
|
+
except requests.exceptions.Timeout as e:
|
|
210
|
+
# 읽기 타임아웃: urllib3 재시도까지 소진된 상태이므로 즉시 변환
|
|
211
|
+
raise EcosNetworkError(f"요청 타임아웃 ({self.timeout}초)") from e
|
|
177
212
|
|
|
178
213
|
except requests.exceptions.ConnectionError as e:
|
|
179
|
-
# urllib3
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
log_retry_attempt(attempt + 1, self.max_retries, last_exception)
|
|
214
|
+
# 연결 오류: urllib3 재시도 소진 후 도달. raw URL(api_key)이 섞일 수
|
|
215
|
+
# 있어 마스킹.
|
|
216
|
+
raise EcosNetworkError(f"네트워크 연결 오류: {mask_api_key(str(e))}") from e
|
|
183
217
|
|
|
184
218
|
except requests.exceptions.HTTPError as e:
|
|
185
|
-
#
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
219
|
+
# 4xx(408/429 제외)는 status_forcelist 밖이라 재시도 없이 즉시 도달하고,
|
|
220
|
+
# 5xx/429는 어댑터가 재시도를 소진한 최종 응답이다. 어느 쪽이든 즉시 raise.
|
|
221
|
+
# requests HTTPError str()에 full URL(key 포함)이 들어가므로 마스킹 필수.
|
|
222
|
+
raise EcosNetworkError(f"HTTP 오류: {mask_api_key(str(e))}") from e
|
|
189
223
|
|
|
190
224
|
except (EcosAPIError, EcosRateLimitError) as e:
|
|
191
|
-
# 재시도
|
|
225
|
+
# 비즈니스 에러: 재시도 대상이 아니면 즉시 raise
|
|
192
226
|
error_key = f"ERROR-{e.code}" if hasattr(e, "code") else ""
|
|
193
|
-
if error_key in RETRYABLE_ERROR_CODES:
|
|
194
|
-
last_exception = e
|
|
195
|
-
if attempt < self.max_retries - 1:
|
|
196
|
-
log_retry_attempt(attempt + 1, self.max_retries, last_exception)
|
|
197
|
-
else:
|
|
227
|
+
if error_key not in RETRYABLE_ERROR_CODES:
|
|
198
228
|
raise
|
|
199
229
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
230
|
+
last_exception = e
|
|
231
|
+
if attempt < self.max_retries - 1:
|
|
232
|
+
log_retry_attempt(attempt + 1, self.max_retries, e)
|
|
233
|
+
# 비즈니스 에러는 HTTP 200이라 Retry-After 헤더가 없으므로
|
|
234
|
+
# exponential backoff 적용
|
|
235
|
+
wait_time = Settings.RETRY_BACKOFF_FACTOR * (2**attempt)
|
|
236
|
+
logger.debug(f"재시도 전 대기: {wait_time:.2f}초")
|
|
237
|
+
time.sleep(wait_time)
|
|
205
238
|
|
|
206
239
|
# 모든 재시도 실패
|
|
207
240
|
if last_exception:
|
|
@@ -232,20 +265,30 @@ class EcosClient:
|
|
|
232
265
|
if code == "INFO-200":
|
|
233
266
|
return
|
|
234
267
|
|
|
235
|
-
#
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
log_error_response(code, message, url)
|
|
244
|
-
|
|
245
|
-
if error_num == "602":
|
|
246
|
-
raise EcosRateLimitError(message)
|
|
268
|
+
# 카탈로그(ECOS_ERROR_CODES) 단일 조회로 코드별 예외 결정
|
|
269
|
+
mapping = ECOS_ERROR_CODES.get(code)
|
|
270
|
+
if mapping is None:
|
|
271
|
+
# 카탈로그 미등록 코드: ERROR 접두는 일반 API 에러로, 그 외는 정상 처리
|
|
272
|
+
if code.startswith("ERROR"):
|
|
273
|
+
log_error_response(code, message, url)
|
|
274
|
+
raise EcosAPIError(self._error_num(code), message)
|
|
275
|
+
return
|
|
247
276
|
|
|
248
|
-
|
|
277
|
+
exc_class, default_message, _retryable = mapping
|
|
278
|
+
log_error_response(code, message, url)
|
|
279
|
+
msg = message or default_message
|
|
280
|
+
|
|
281
|
+
# EcosAPIError 계열은 (code, message) 시그니처, 그 외(Config/RateLimit)는 (message)
|
|
282
|
+
if issubclass(exc_class, EcosAPIError):
|
|
283
|
+
raise exc_class(self._error_num(code), msg)
|
|
284
|
+
raise exc_class(msg)
|
|
285
|
+
|
|
286
|
+
@staticmethod
|
|
287
|
+
def _error_num(code: str) -> str:
|
|
288
|
+
"""``ERROR-100`` → ``100``처럼 코드에서 숫자 부분을 추출합니다."""
|
|
289
|
+
if "-" in code:
|
|
290
|
+
return code.split("-", 1)[-1]
|
|
291
|
+
return code.replace("ERROR", "")
|
|
249
292
|
|
|
250
293
|
def _cached_request(
|
|
251
294
|
self,
|
|
@@ -139,6 +139,12 @@ class Settings:
|
|
|
139
139
|
DEFAULT_TIMEOUT: int = 30 # 초
|
|
140
140
|
MAX_RETRIES: int = 3
|
|
141
141
|
RETRY_BACKOFF_FACTOR: float = 1.0
|
|
142
|
+
# 재시도 대상 HTTP 상태 코드. 4xx 중에서는 408(timeout)/429(rate limit)만 포함하고
|
|
143
|
+
# 나머지 4xx(클라이언트 오류)는 재시도하지 않는다.
|
|
144
|
+
RETRY_STATUS_FORCELIST: tuple[int, ...] = (408, 429, 500, 502, 503, 504)
|
|
145
|
+
# 커넥션 풀 크기 (대량 동시 요청 시 기본값 10 병목 완화)
|
|
146
|
+
POOL_CONNECTIONS: int = 10
|
|
147
|
+
POOL_MAXSIZE: int = 20
|
|
142
148
|
|
|
143
149
|
# 캐시 설정
|
|
144
150
|
CACHE_TTL: int = 3600 # 1시간
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ecos-reader 예외 클래스 정의
|
|
3
|
+
|
|
4
|
+
ECOS API 에러 및 라이브러리 예외를 처리합니다.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class EcosError(Exception):
|
|
11
|
+
"""ecos-reader 기본 예외"""
|
|
12
|
+
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class EcosAPIError(EcosError):
|
|
17
|
+
"""ECOS API 호출 에러
|
|
18
|
+
|
|
19
|
+
Attributes
|
|
20
|
+
----------
|
|
21
|
+
code : str
|
|
22
|
+
ECOS API 에러 코드
|
|
23
|
+
message : str
|
|
24
|
+
에러 메시지
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, code: str, message: str):
|
|
28
|
+
self.code = code
|
|
29
|
+
self.message = message
|
|
30
|
+
super().__init__(f"[{code}] {message}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class EcosConfigError(EcosError):
|
|
34
|
+
"""설정 관련 에러 (API Key 등)"""
|
|
35
|
+
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class EcosNetworkError(EcosError):
|
|
40
|
+
"""네트워크 연결 에러"""
|
|
41
|
+
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class EcosValidationError(EcosAPIError):
|
|
46
|
+
"""클라이언트 책임 요청 오류 (4xx 성격)
|
|
47
|
+
|
|
48
|
+
필수값 누락·형식/타입 오류 등 요청 자체가 잘못된 경우. 동일 요청을
|
|
49
|
+
재시도해도 동일하게 실패하므로 재시도 대상이 아닙니다.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class EcosServerError(EcosAPIError):
|
|
56
|
+
"""ECOS 서버측 오류 (5xx 성격)
|
|
57
|
+
|
|
58
|
+
서버 오류·DB Connection·SQL 오류 등. 일시적 오류(서버/DB)는 재시도
|
|
59
|
+
대상일 수 있습니다 (`ECOS_ERROR_CODES`의 retryable 플래그 참조).
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class EcosRateLimitError(EcosError):
|
|
66
|
+
"""Rate Limit 초과"""
|
|
67
|
+
|
|
68
|
+
def __init__(self, message: str, code: str = "602"):
|
|
69
|
+
self.code = code
|
|
70
|
+
self.message = message
|
|
71
|
+
super().__init__(f"[{code}] {message}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ECOS 에러 코드 단일 진실원천 (Single Source of Truth)
|
|
75
|
+
#
|
|
76
|
+
# 키는 ECOS API가 실제로 반환하는 하이픈 형식("ERROR-602")으로 통일한다.
|
|
77
|
+
# 값은 (예외 클래스, 기본 메시지, 재시도 가능 여부) 3-튜플이다.
|
|
78
|
+
# 클라이언트는 이 매핑을 조회해 코드별 예외를 일관되게 발생시키고,
|
|
79
|
+
# RETRYABLE_ERROR_CODES도 여기서 파생한다.
|
|
80
|
+
ECOS_ERROR_CODES: dict[str, tuple[type[EcosError], str, bool]] = {
|
|
81
|
+
# 정보 코드
|
|
82
|
+
"INFO-100": (EcosConfigError, "인증키가 유효하지 않습니다.", False),
|
|
83
|
+
"INFO-200": (EcosAPIError, "해당하는 데이터가 없습니다.", False), # 빈 결과로 처리
|
|
84
|
+
# 클라이언트 책임(요청 검증) 에러 — 재시도해도 동일 실패
|
|
85
|
+
"ERROR-100": (EcosValidationError, "필수 값이 누락되어 있습니다.", False),
|
|
86
|
+
"ERROR-101": (EcosValidationError, "주기와 다른 형식의 날짜 형식입니다.", False),
|
|
87
|
+
"ERROR-200": (EcosValidationError, "파일타입 값이 누락 혹은 유효하지 않습니다.", False),
|
|
88
|
+
"ERROR-300": (EcosValidationError, "조회건수 값이 누락되어 있습니다.", False),
|
|
89
|
+
"ERROR-301": (EcosValidationError, "조회건수 값의 타입이 유효하지 않습니다.", False),
|
|
90
|
+
"ERROR-400": (EcosAPIError, "검색범위가 적정범위를 초과하여 TIMEOUT이 발생하였습니다.", False),
|
|
91
|
+
# 서버측 에러 — 서버/DB는 일시적일 수 있어 재시도, SQL 오류는 비재시도
|
|
92
|
+
"ERROR-500": (EcosServerError, "서버 오류입니다.", True),
|
|
93
|
+
"ERROR-600": (EcosServerError, "DB Connection 오류입니다.", True),
|
|
94
|
+
"ERROR-601": (EcosServerError, "SQL 오류입니다.", False),
|
|
95
|
+
# Rate limit
|
|
96
|
+
"ERROR-602": (EcosRateLimitError, "과도한 OpenAPI 호출로 이용이 제한되었습니다.", True),
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
# 재시도 가능한 에러 코드 — ECOS_ERROR_CODES의 retryable 플래그에서 파생
|
|
100
|
+
RETRYABLE_ERROR_CODES: frozenset[str] = frozenset(
|
|
101
|
+
code for code, (_exc, _msg, retryable) in ECOS_ERROR_CODES.items() if retryable
|
|
102
|
+
)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""
|
|
2
|
+
indicators 공용 날짜 헬퍼.
|
|
3
|
+
|
|
4
|
+
기본 조회 기간(시작/종료)을 빈도별로 계산하는 단일 진실원천 모듈입니다.
|
|
5
|
+
모든 함수는 ECOS API가 요구하는 문자열 형식을 반환합니다:
|
|
6
|
+
|
|
7
|
+
- ``default_daily`` → ``YYYYMMDD``
|
|
8
|
+
- ``default_monthly`` → ``YYYYMM``
|
|
9
|
+
- ``default_quarterly`` → ``YYYYQN``
|
|
10
|
+
- ``default_annual`` → ``YYYY``
|
|
11
|
+
|
|
12
|
+
각 함수는 ``today`` 인자로 기준일을 주입할 수 있어 테스트에서 결정적으로
|
|
13
|
+
동작합니다 (기본값은 ``datetime.now()``).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from datetime import datetime, timedelta
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _reference(today: datetime | None) -> datetime:
|
|
22
|
+
"""기준일을 반환합니다. ``None``이면 현재 시각."""
|
|
23
|
+
return today if today is not None else datetime.now()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def default_daily(days_back: int = 365, *, today: datetime | None = None) -> tuple[str, str]:
|
|
27
|
+
"""일별 기본 조회 기간을 ``YYYYMMDD`` 형식으로 반환합니다.
|
|
28
|
+
|
|
29
|
+
``timedelta`` 기반이므로 윤년 2/29 경계에서도
|
|
30
|
+
``ValueError: day is out of range`` 없이 안전합니다.
|
|
31
|
+
"""
|
|
32
|
+
end = _reference(today)
|
|
33
|
+
start = end - timedelta(days=days_back)
|
|
34
|
+
return start.strftime("%Y%m%d"), end.strftime("%Y%m%d")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def default_monthly(months_back: int = 12, *, today: datetime | None = None) -> tuple[str, str]:
|
|
38
|
+
"""월별 기본 조회 기간을 ``YYYYMM`` 형식으로 반환합니다."""
|
|
39
|
+
end = _reference(today)
|
|
40
|
+
|
|
41
|
+
total_months = end.year * 12 + end.month
|
|
42
|
+
start_total_months = total_months - months_back
|
|
43
|
+
|
|
44
|
+
start_year = (start_total_months - 1) // 12
|
|
45
|
+
start_month = (start_total_months - 1) % 12 + 1
|
|
46
|
+
|
|
47
|
+
return f"{start_year}{start_month:02d}", f"{end.year}{end.month:02d}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def default_quarterly(years_back: int = 5, *, today: datetime | None = None) -> tuple[str, str]:
|
|
51
|
+
"""분기별 기본 조회 기간을 ``YYYYQN`` 형식으로 반환합니다.
|
|
52
|
+
|
|
53
|
+
시작은 ``years_back`` 년 전의 1분기, 종료는 기준일이 속한 분기입니다.
|
|
54
|
+
"""
|
|
55
|
+
end = _reference(today)
|
|
56
|
+
start_year = end.year - years_back
|
|
57
|
+
current_quarter = (end.month - 1) // 3 + 1
|
|
58
|
+
return f"{start_year}Q1", f"{end.year}Q{current_quarter}"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def default_annual(years_back: int = 10, *, today: datetime | None = None) -> tuple[str, str]:
|
|
62
|
+
"""연간 기본 조회 기간을 ``YYYY`` 형식으로 반환합니다."""
|
|
63
|
+
end = _reference(today)
|
|
64
|
+
return str(end.year - years_back), str(end.year)
|
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
-
from datetime import datetime
|
|
10
9
|
from typing import Literal
|
|
11
10
|
|
|
12
11
|
import pandas as pd
|
|
@@ -18,27 +17,10 @@ from ..constants import (
|
|
|
18
17
|
STAT_BOND_YIELD_TYPE,
|
|
19
18
|
)
|
|
20
19
|
from ..parser import normalize_stat_result, parse_response
|
|
20
|
+
from ._dates import default_monthly
|
|
21
21
|
from ._deprecations import warn_partial_coverage as _warn_partial_coverage
|
|
22
22
|
|
|
23
23
|
|
|
24
|
-
def _get_default_dates(months_back: int = 24) -> tuple[str, str]:
|
|
25
|
-
"""기본 조회 기간을 반환합니다 (기본 2년)."""
|
|
26
|
-
end_date = datetime.now()
|
|
27
|
-
|
|
28
|
-
# 총 개월 수 계산
|
|
29
|
-
total_months = end_date.year * 12 + end_date.month
|
|
30
|
-
start_total_months = total_months - months_back
|
|
31
|
-
|
|
32
|
-
# 연도와 월 계산
|
|
33
|
-
start_year = (start_total_months - 1) // 12
|
|
34
|
-
start_month = (start_total_months - 1) % 12 + 1
|
|
35
|
-
|
|
36
|
-
start_str = f"{start_year}{start_month:02d}"
|
|
37
|
-
end_str = f"{end_date.year}{end_date.month:02d}"
|
|
38
|
-
|
|
39
|
-
return start_str, end_str
|
|
40
|
-
|
|
41
|
-
|
|
42
24
|
def get_bond_yield(
|
|
43
25
|
bond_type: Literal["종류별", "시장별"] = "종류별",
|
|
44
26
|
start_date: str | None = None,
|
|
@@ -106,7 +88,7 @@ def get_bond_yield(
|
|
|
106
88
|
|
|
107
89
|
# 기본 날짜 설정
|
|
108
90
|
if start_date is None or end_date is None:
|
|
109
|
-
default_start, default_end =
|
|
91
|
+
default_start, default_end = default_monthly(24)
|
|
110
92
|
start_date = start_date or default_start
|
|
111
93
|
end_date = end_date or default_end
|
|
112
94
|
|
|
@@ -6,8 +6,6 @@
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
-
from datetime import datetime
|
|
10
|
-
|
|
11
9
|
import pandas as pd
|
|
12
10
|
|
|
13
11
|
from ..client import get_client
|
|
@@ -17,24 +15,7 @@ from ..constants import (
|
|
|
17
15
|
STAT_FISCAL_BALANCE,
|
|
18
16
|
)
|
|
19
17
|
from ..parser import normalize_stat_result, parse_response
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def _get_default_dates(months_back: int = 24) -> tuple[str, str]:
|
|
23
|
-
"""기본 조회 기간을 반환합니다 (기본 2년)."""
|
|
24
|
-
end_date = datetime.now()
|
|
25
|
-
|
|
26
|
-
# 총 개월 수 계산
|
|
27
|
-
total_months = end_date.year * 12 + end_date.month
|
|
28
|
-
start_total_months = total_months - months_back
|
|
29
|
-
|
|
30
|
-
# 연도와 월 계산
|
|
31
|
-
start_year = (start_total_months - 1) // 12
|
|
32
|
-
start_month = (start_total_months - 1) % 12 + 1
|
|
33
|
-
|
|
34
|
-
start_str = f"{start_year}{start_month:02d}"
|
|
35
|
-
end_str = f"{end_date.year}{end_date.month:02d}"
|
|
36
|
-
|
|
37
|
-
return start_str, end_str
|
|
18
|
+
from ._dates import default_monthly
|
|
38
19
|
|
|
39
20
|
|
|
40
21
|
def get_fiscal_balance(
|
|
@@ -84,7 +65,7 @@ def get_fiscal_balance(
|
|
|
84
65
|
"""
|
|
85
66
|
# 기본 날짜 설정
|
|
86
67
|
if start_date is None or end_date is None:
|
|
87
|
-
default_start, default_end =
|
|
68
|
+
default_start, default_end = default_monthly(24)
|
|
88
69
|
start_date = start_date or default_start
|
|
89
70
|
end_date = end_date or default_end
|
|
90
71
|
|