ls증권 히스토리 구독 넣음
This commit is contained in:
@@ -10,6 +10,10 @@ REST access_token(.kis_token_cache_*.json) 과 동일 패턴:
|
||||
문제(기존):
|
||||
- kis_ws / kis_ws_overseas 가 각각 /oauth2/Approval 호출 → 나중 키가 앞 키 무효화
|
||||
- 메모리만 사용 → 재시작마다 새 발급 → invalid approval 루프
|
||||
|
||||
응급 예외(invalid approval):
|
||||
- 서버가 캐시 키를 이미 거부하는데 6h 가드만 지키면 동일 키 루프
|
||||
- bypass_min_reissue / emergency_reissue 로 REST 재발급 허용
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -34,6 +38,9 @@ LOCK_TIMEOUT_S = 60
|
||||
|
||||
DEFAULT_MIN_REISSUE_SEC = 21600.0
|
||||
DEFAULT_MAX_VALID_SEC = 82800.0
|
||||
# 응급 재발급 하드캡 파일 (실전/모의 공통 간격 — 앱키당 1키 정책)
|
||||
EMERGENCY_META_REAL = ROOT / ".kis_approval_emergency_real.json"
|
||||
EMERGENCY_META_MOCK = ROOT / ".kis_approval_emergency_mock.json"
|
||||
|
||||
_am_instances: dict = {}
|
||||
_am_instances_lock = threading.Lock()
|
||||
@@ -55,6 +62,18 @@ def _max_valid_sec() -> float:
|
||||
return DEFAULT_MAX_VALID_SEC
|
||||
|
||||
|
||||
def _emergency_cap_sec() -> float:
|
||||
"""응급 재발급 최소 간격(초). 기본=6h — 영구정지 위험 완화."""
|
||||
try:
|
||||
raw = os.environ.get("KIS_WS_EMERGENCY_REISSUE_MIN_SEC", "").strip()
|
||||
if raw:
|
||||
return max(0.0, float(raw))
|
||||
# 미설정 시 정상 6h 가드와 동일
|
||||
return _min_reissue_sec()
|
||||
except (ValueError, TypeError):
|
||||
return DEFAULT_MIN_REISSUE_SEC
|
||||
|
||||
|
||||
def _acquire_lock() -> bool:
|
||||
deadline = time.time() + LOCK_TIMEOUT_S
|
||||
while time.time() < deadline:
|
||||
@@ -177,6 +196,109 @@ class KISApprovalManager:
|
||||
self._load_from_file()
|
||||
return self._approval_key
|
||||
|
||||
def clear_cached_key(self, reason: str = "") -> None:
|
||||
"""메모리·파일 캐시 삭제 (죽은 키 루프 탈출용)."""
|
||||
with self._lock:
|
||||
had = bool(self._approval_key)
|
||||
self._approval_key = None
|
||||
self._issued_ts = 0.0
|
||||
self._app_key_prefix = ""
|
||||
try:
|
||||
self._cache_path.unlink(missing_ok=True)
|
||||
except Exception as exc:
|
||||
logger.warning("approval 캐시 파일 삭제 실패: %s", exc)
|
||||
if had or reason:
|
||||
logger.info(
|
||||
"🧹 approval_key 캐시 삭제 [%s]%s",
|
||||
self._mode_str,
|
||||
f" ({reason})" if reason else "",
|
||||
)
|
||||
|
||||
def _emergency_meta_path(self) -> Path:
|
||||
return EMERGENCY_META_MOCK if self._is_mock else EMERGENCY_META_REAL
|
||||
|
||||
def _last_emergency_ts(self) -> float:
|
||||
path = self._emergency_meta_path()
|
||||
if not path.exists():
|
||||
return 0.0
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
return float(data.get("last_emergency_ts") or 0.0)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
def _save_emergency_ts(self, ts: float, reason: str = "") -> None:
|
||||
path = self._emergency_meta_path()
|
||||
try:
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"last_emergency_ts": ts,
|
||||
"last_emergency_at": datetime.fromtimestamp(ts).strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
),
|
||||
"reason": (reason or "")[:200],
|
||||
"mock": self._is_mock,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("응급 재발급 메타 저장 실패: %s", exc)
|
||||
|
||||
def emergency_reissue(
|
||||
self,
|
||||
app_key: str,
|
||||
app_secret: str,
|
||||
base_url: str,
|
||||
*,
|
||||
reason: str = "invalid approval",
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
서버가 캐시 키를 거부할 때 6h 가드를 우회해 REST 재발급.
|
||||
★ 하드캡: KIS_WS_EMERGENCY_REISSUE_MIN_SEC(기본 6h) 내 1회만 허용.
|
||||
(정상 재연결 경로에서는 호출하지 말 것 — invalid 연속·수동 응급만)
|
||||
"""
|
||||
cap = _emergency_cap_sec()
|
||||
last = self._last_emergency_ts()
|
||||
now = time.time()
|
||||
if cap > 0 and last > 0 and (now - last) < cap:
|
||||
remain = cap - (now - last)
|
||||
logger.error(
|
||||
"⛔ approval 응급 재발급 하드캡 — %.0f분 전 이미 1회 사용 "
|
||||
"(남은 대기 %.0f분, 간격 %.0fh). 죽은 키 루프여도 REST 재발급 안 함. "
|
||||
"reason=%s",
|
||||
(now - last) / 60.0,
|
||||
remain / 60.0,
|
||||
cap / 3600.0,
|
||||
reason,
|
||||
)
|
||||
# 캐시가 있으면 그대로 반환(루프는 WS 쪽 쿨다운), 없으면 None
|
||||
with self._lock:
|
||||
self._load_from_file()
|
||||
return self._approval_key
|
||||
|
||||
logger.warning(
|
||||
"🚨 approval_key 응급 REST 재발급 시작 [%s] reason=%s "
|
||||
"(하드캡: %.0fh에 1회)",
|
||||
self._mode_str,
|
||||
reason,
|
||||
cap / 3600.0 if cap > 0 else 0.0,
|
||||
)
|
||||
self.clear_cached_key(reason=reason)
|
||||
key = self.get_approval_key(
|
||||
app_key,
|
||||
app_secret,
|
||||
base_url,
|
||||
force_refresh=True,
|
||||
bypass_min_reissue=True,
|
||||
)
|
||||
if key:
|
||||
self._save_emergency_ts(time.time(), reason=reason)
|
||||
return key
|
||||
|
||||
@property
|
||||
def issued_ts(self) -> float:
|
||||
return self._issued_ts
|
||||
@@ -188,11 +310,13 @@ class KISApprovalManager:
|
||||
base_url: str,
|
||||
*,
|
||||
force_refresh: bool = False,
|
||||
bypass_min_reissue: bool = False,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
유효한 approval_key 반환.
|
||||
- 24h 이내: 파일/메모리 캐시 재사용 (재연결 포함)
|
||||
- 6h 미경과: force_refresh=True 여도 REST 재발급 금지
|
||||
(단 bypass_min_reissue=True 이면 응급 재발급 허용)
|
||||
- 6h 경과 + 24h 미만 + force_refresh: REST 재발급 허용
|
||||
- 24h 경과: REST 재발급
|
||||
"""
|
||||
@@ -213,9 +337,9 @@ class KISApprovalManager:
|
||||
or self._app_key_prefix.startswith(app_key[:8])
|
||||
)
|
||||
if self._approval_key and age < max_valid and prefix_ok:
|
||||
if force_refresh and age >= min_reissue:
|
||||
if force_refresh and (bypass_min_reissue or age >= min_reissue):
|
||||
pass
|
||||
elif force_refresh and age < min_reissue:
|
||||
elif force_refresh and age < min_reissue and not bypass_min_reissue:
|
||||
logger.info(
|
||||
"🔑 approval_key REST 재발급 생략 (KIS 6h — %.0f분/%.0fh, %s 재사용)",
|
||||
age / 60,
|
||||
@@ -236,7 +360,9 @@ class KISApprovalManager:
|
||||
self._load_from_file()
|
||||
age = self.age_sec()
|
||||
if self._approval_key and age < max_valid:
|
||||
if not force_refresh or age < min_reissue:
|
||||
if not force_refresh:
|
||||
return self._approval_key
|
||||
if age < min_reissue and not bypass_min_reissue:
|
||||
return self._approval_key
|
||||
|
||||
key = _issue_rest(app_key, app_secret, base_url)
|
||||
@@ -252,11 +378,12 @@ class KISApprovalManager:
|
||||
self._save_to_file()
|
||||
logger.info(
|
||||
"✅ WebSocket approval_key 발급·저장 (%s, 앞8자: %s…, "
|
||||
"다음 REST 가능: %.0fh 후 → %s)",
|
||||
"다음 REST 가능: %.0fh 후 → %s%s)",
|
||||
self._mode_str,
|
||||
key[:8],
|
||||
min_reissue / 3600,
|
||||
self._cache_path.name,
|
||||
", 응급우회" if bypass_min_reissue else "",
|
||||
)
|
||||
return key
|
||||
finally:
|
||||
|
||||
Reference in New Issue
Block a user