ls증권 히스토리 구독 넣음

This commit is contained in:
Your Name
2026-07-30 18:05:07 +09:00
parent 61bec4bd1d
commit 67eab24603
1593 changed files with 135733 additions and 1232 deletions

View File

@@ -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: