Files
kis_bot/kis_approval_manager.py
Your Name 0ecac7cb95 이번에 들어간 내용
한투 호가 = 2번째 앱키 전용
키 없거나 start 실패 시 메인에 H0STASP0 안 붙임. 운영설정 WS_ORDERBOOK_SAVE_KIS 빨간 danger.

LS RAM 합집합
후보∪보유∪영구∪grace. sync_targets와 split reconcile 둘 다. 틱 DB 영구 게이트는 그대로.

분봉 쓰레기 → 다음 소스 봉 통째
그 분 틱 0건이거나 전부 봉끝 대비 LIVE_FEED_FALLBACK_MAX_AGE_SEC 초과면 구멍. 메인 WS → 2차 → LS → REST → rollup. CANDLE_GARBAGE_FALLBACK 기본 true.

파일: feed_fallback.py(신규), ws_manager.py, kis_ws.py, candle_series.py, bt_candle_source.py, live_config_schema.py, database.py, 스모크, MD 2개.

같은 ws_manager/database/kis_ws/live_config에는 직전 커밋 이후 쌓여 있던 시세 폴백·ENV 키 정리도 같이 들어갔습니다. 파일 단위로 나눌 수 없어서입니다.
2026-08-19 22:11:31 +09:00

448 lines
16 KiB
Python

#!/usr/bin/env python3
"""
kis_approval_manager.py — KIS WebSocket approval_key 파일 캐시
==================================================================================
REST access_token(.kis_token_cache_*.json) 과 동일 패턴:
- slot=main: 국내·해외 WS 가 **같은 앱키**의 approval 1개를 공유
(.kis_approval_cache_real.json / _mock.json)
- slot=ob: 호가 전용 2번째 앱키 — **별도 JSON** (메인 파일을 덮지 않음)
(.kis_approval_cache_ob_real.json / _ob_mock.json)
- KIS 정책: 24h 유효, 6h 이내 REST 재발급 금지, 앱키당 1세션
- 파일 잠금은 슬롯별 (메인 발급이 호가 발급을 막지 않음)
문제(기존):
- kis_ws / kis_ws_overseas 가 각각 /oauth2/Approval 호출 → 나중 키가 앞 키 무효화
- 메모리만 사용 → 재시작마다 새 발급 → invalid approval 루프
응급 예외(invalid approval):
- 서버가 캐시 키를 이미 거부하는데 6h 가드만 지키면 동일 키 루프
- bypass_min_reissue / emergency_reissue 로 REST 재발급 허용
"""
from __future__ import annotations
import json
import logging
import os
import threading
import time
from datetime import datetime
from pathlib import Path
from typing import Optional
import requests
logger = logging.getLogger(__name__)
ROOT = Path(__file__).parent
CACHE_MOCK = ROOT / ".kis_approval_cache_mock.json"
CACHE_REAL = ROOT / ".kis_approval_cache_real.json"
CACHE_OB_MOCK = ROOT / ".kis_approval_cache_ob_mock.json"
CACHE_OB_REAL = ROOT / ".kis_approval_cache_ob_real.json"
LOCK_FILE = ROOT / ".kis_approval_manager.lock"
LOCK_FILE_OB = ROOT / ".kis_approval_manager_ob.lock"
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"
EMERGENCY_META_OB_REAL = ROOT / ".kis_approval_emergency_ob_real.json"
EMERGENCY_META_OB_MOCK = ROOT / ".kis_approval_emergency_ob_mock.json"
def _norm_slot(slot: str) -> str:
s = str(slot or "main").strip().lower()
return s if s in ("main", "ob") else "main"
def _approval_cache_path(is_mock: bool, slot: str) -> Path:
slot = _norm_slot(slot)
if slot == "ob":
return CACHE_OB_MOCK if is_mock else CACHE_OB_REAL
return CACHE_MOCK if is_mock else CACHE_REAL
def _approval_lock_path(slot: str) -> Path:
return LOCK_FILE_OB if _norm_slot(slot) == "ob" else LOCK_FILE
def _approval_emergency_path(is_mock: bool, slot: str) -> Path:
slot = _norm_slot(slot)
if slot == "ob":
return EMERGENCY_META_OB_MOCK if is_mock else EMERGENCY_META_OB_REAL
return EMERGENCY_META_MOCK if is_mock else EMERGENCY_META_REAL
_am_instances: dict = {}
_am_instances_lock = threading.Lock()
def _min_reissue_sec() -> float:
try:
raw = os.environ.get("KIS_WS_APPROVAL_MIN_REISSUE_SEC", "").strip()
return max(0.0, float(raw)) if raw else DEFAULT_MIN_REISSUE_SEC
except (ValueError, TypeError):
return DEFAULT_MIN_REISSUE_SEC
def _max_valid_sec() -> float:
try:
raw = os.environ.get("KIS_WS_APPROVAL_MAX_VALID_SEC", "").strip()
return max(3600.0, float(raw)) if raw else DEFAULT_MAX_VALID_SEC
except (ValueError, TypeError):
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(lock_path: Path) -> bool:
deadline = time.time() + LOCK_TIMEOUT_S
while time.time() < deadline:
try:
fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
os.write(fd, str(os.getpid()).encode())
os.close(fd)
return True
except FileExistsError:
try:
if time.time() - lock_path.stat().st_mtime > 300:
lock_path.unlink(missing_ok=True)
continue
except Exception:
pass
time.sleep(1)
except Exception as exc:
logger.warning("approval 잠금 획득 실패: %s", exc)
return False
logger.warning("approval 잠금 타임아웃 → 파일 캐시 재사용 시도")
return False
def _release_lock(lock_path: Path) -> None:
try:
lock_path.unlink(missing_ok=True)
except Exception:
pass
def _issue_rest(app_key: str, app_secret: str, base_url: str) -> Optional[str]:
url = f"{base_url.rstrip('/')}/oauth2/Approval"
body = {
"grant_type": "client_credentials",
"appkey": app_key,
"secretkey": app_secret,
}
try:
resp = requests.post(url, json=body, timeout=10)
data = resp.json()
key = str(data.get("approval_key") or "").strip()
if key:
return key
logger.error("❌ approval_key REST 발급 실패: %s", data)
except Exception as exc:
logger.error("❌ approval_key REST 요청 예외: %s", exc)
return None
class KISApprovalManager:
"""KIS WebSocket approval_key 싱글톤 — (실전/모의, slot=main|ob) 각 1개."""
@classmethod
def instance(cls, is_mock: bool, slot: str = "main") -> "KISApprovalManager":
key = (bool(is_mock), _norm_slot(slot))
with _am_instances_lock:
if key not in _am_instances:
_am_instances[key] = cls(key[0], slot=key[1])
return _am_instances[key]
def __init__(self, is_mock: bool, slot: str = "main") -> None:
self._is_mock = bool(is_mock)
self._slot = _norm_slot(slot)
mode = "모의" if is_mock else "실전"
self._mode_str = f"{mode}/{self._slot}"
self._cache_path = _approval_cache_path(is_mock, self._slot)
self._lock_path = _approval_lock_path(self._slot)
self._lock = threading.Lock()
self._approval_key: Optional[str] = None
self._issued_ts: float = 0.0
self._app_key_prefix: str = ""
self._load_from_file()
def _load_from_file(self) -> None:
if not self._cache_path.exists():
return
try:
data = json.loads(self._cache_path.read_text(encoding="utf-8"))
key = str(data.get("approval_key") or "").strip()
ts = float(data.get("issued_at_epoch") or 0.0)
prefix = str(data.get("app_key_prefix") or "")
if not key or ts <= 0:
return
age = time.time() - ts
if age >= _max_valid_sec():
return
self._approval_key = key
self._issued_ts = ts
self._app_key_prefix = prefix
except Exception as exc:
logger.debug("approval 파일 로드 실패 [%s]: %s", self._mode_str, exc)
def _save_to_file(self) -> None:
if not self._approval_key or self._issued_ts <= 0:
return
try:
self._cache_path.write_text(
json.dumps(
{
"approval_key": self._approval_key,
"issued_at": datetime.fromtimestamp(self._issued_ts).strftime(
"%Y-%m-%d %H:%M:%S"
),
"issued_at_epoch": self._issued_ts,
"app_key_prefix": self._app_key_prefix,
"mock": self._is_mock,
"slot": self._slot,
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
except Exception as exc:
logger.warning("approval 파일 저장 실패 (%s): %s", self._cache_path.name, exc)
def age_sec(self) -> float:
if not self._issued_ts:
return 999999.0
return max(0.0, time.time() - self._issued_ts)
def reload_from_file(self) -> Optional[str]:
"""REST 없이 파일 캐시만 다시 읽기 (다른 프로세스 갱신 반영)."""
with self._lock:
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 _approval_emergency_path(self._is_mock, self._slot)
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,
"slot": self._slot,
},
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
def get_approval_key(
self,
app_key: str,
app_secret: str,
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 재발급
"""
app_key = (app_key or "").strip()
app_secret = (app_secret or "").strip()
if not app_key or not app_secret:
return None
min_reissue = _min_reissue_sec()
max_valid = _max_valid_sec()
with self._lock:
self._load_from_file()
age = self.age_sec()
prefix_ok = (
not self._app_key_prefix
or app_key.startswith(self._app_key_prefix)
or self._app_key_prefix.startswith(app_key[:8])
)
if self._approval_key and age < max_valid and prefix_ok:
if force_refresh and (bypass_min_reissue or age >= min_reissue):
pass
elif force_refresh and age < min_reissue and not bypass_min_reissue:
logger.info(
"🔑 approval_key REST 재발급 생략 (KIS 6h — %.0f분/%.0fh, %s 재사용)",
age / 60,
min_reissue / 3600,
self._cache_path.name,
)
return self._approval_key
else:
return self._approval_key
if not _acquire_lock(self._lock_path):
with self._lock:
self._load_from_file()
return self._approval_key
try:
with self._lock:
self._load_from_file()
age = self.age_sec()
prefix_ok = (
not self._app_key_prefix
or app_key.startswith(self._app_key_prefix)
or self._app_key_prefix.startswith(app_key[:8])
)
if self._approval_key and age < max_valid and prefix_ok:
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)
if not key:
with self._lock:
return self._approval_key
now = time.time()
with self._lock:
self._approval_key = key
self._issued_ts = now
self._app_key_prefix = app_key[:8]
self._save_to_file()
logger.info(
"✅ WebSocket approval_key 발급·저장 (%s, 앞8자: %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:
_release_lock(self._lock_path)
def get_approval_status(is_mock: bool, slot: str = "main") -> dict:
mgr = KISApprovalManager.instance(is_mock, slot=slot)
with mgr._lock:
mgr._load_from_file()
age_h = round(mgr.age_sec() / 3600.0, 2) if mgr._approval_key else -1.0
valid = bool(mgr._approval_key) and mgr.age_sec() < _max_valid_sec()
return {
"valid": valid,
"key_prefix": (mgr._approval_key[:8] + "") if mgr._approval_key else "",
"age_h": age_h,
"file": str(mgr._cache_path.name),
"slot": mgr._slot,
}