이번에 들어간 내용
한투 호가 = 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 키 정리도 같이 들어갔습니다. 파일 단위로 나눌 수 없어서입니다.
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
kis_approval_manager.py — KIS WebSocket approval_key 파일 캐시 (국내·해외 WS 공유)
|
||||
kis_approval_manager.py — KIS WebSocket approval_key 파일 캐시
|
||||
==================================================================================
|
||||
REST access_token(.kis_token_cache_*.json) 과 동일 패턴:
|
||||
- 프로세스·재시작·국내/해외 WS 가 **하나의 approval_key** 를 공유
|
||||
- KIS 정책: 24h 유효, 6h 이내 REST 재발급 금지
|
||||
- 파일 잠금으로 동시 발급(국내+해외·재시작) 경합 방지
|
||||
- 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 호출 → 나중 키가 앞 키 무효화
|
||||
@@ -33,14 +36,42 @@ 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키 정책)
|
||||
# 응급 재발급 하드캡 파일 (슬롯·실전/모의 분리 — 앱키당 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()
|
||||
@@ -74,18 +105,18 @@ def _emergency_cap_sec() -> float:
|
||||
return DEFAULT_MIN_REISSUE_SEC
|
||||
|
||||
|
||||
def _acquire_lock() -> bool:
|
||||
def _acquire_lock(lock_path: Path) -> bool:
|
||||
deadline = time.time() + LOCK_TIMEOUT_S
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
fd = os.open(str(LOCK_FILE), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||
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_FILE.stat().st_mtime > 300:
|
||||
LOCK_FILE.unlink(missing_ok=True)
|
||||
if time.time() - lock_path.stat().st_mtime > 300:
|
||||
lock_path.unlink(missing_ok=True)
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
@@ -97,9 +128,9 @@ def _acquire_lock() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _release_lock() -> None:
|
||||
def _release_lock(lock_path: Path) -> None:
|
||||
try:
|
||||
LOCK_FILE.unlink(missing_ok=True)
|
||||
lock_path.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -124,19 +155,23 @@ def _issue_rest(app_key: str, app_secret: str, base_url: str) -> Optional[str]:
|
||||
|
||||
|
||||
class KISApprovalManager:
|
||||
"""KIS WebSocket approval_key 싱글톤 — 실전/모의 각 1개."""
|
||||
"""KIS WebSocket approval_key 싱글톤 — (실전/모의, slot=main|ob) 각 1개."""
|
||||
|
||||
@classmethod
|
||||
def instance(cls, is_mock: bool) -> "KISApprovalManager":
|
||||
def instance(cls, is_mock: bool, slot: str = "main") -> "KISApprovalManager":
|
||||
key = (bool(is_mock), _norm_slot(slot))
|
||||
with _am_instances_lock:
|
||||
if is_mock not in _am_instances:
|
||||
_am_instances[is_mock] = cls(is_mock)
|
||||
return _am_instances[is_mock]
|
||||
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) -> None:
|
||||
def __init__(self, is_mock: bool, slot: str = "main") -> None:
|
||||
self._is_mock = bool(is_mock)
|
||||
self._mode_str = "모의" if is_mock else "실전"
|
||||
self._cache_path = CACHE_MOCK if is_mock else CACHE_REAL
|
||||
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
|
||||
@@ -176,6 +211,7 @@ class KISApprovalManager:
|
||||
"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,
|
||||
@@ -215,7 +251,7 @@ class KISApprovalManager:
|
||||
)
|
||||
|
||||
def _emergency_meta_path(self) -> Path:
|
||||
return EMERGENCY_META_MOCK if self._is_mock else EMERGENCY_META_REAL
|
||||
return _approval_emergency_path(self._is_mock, self._slot)
|
||||
|
||||
def _last_emergency_ts(self) -> float:
|
||||
path = self._emergency_meta_path()
|
||||
@@ -239,6 +275,7 @@ class KISApprovalManager:
|
||||
),
|
||||
"reason": (reason or "")[:200],
|
||||
"mock": self._is_mock,
|
||||
"slot": self._slot,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
@@ -350,7 +387,7 @@ class KISApprovalManager:
|
||||
else:
|
||||
return self._approval_key
|
||||
|
||||
if not _acquire_lock():
|
||||
if not _acquire_lock(self._lock_path):
|
||||
with self._lock:
|
||||
self._load_from_file()
|
||||
return self._approval_key
|
||||
@@ -359,7 +396,12 @@ class KISApprovalManager:
|
||||
with self._lock:
|
||||
self._load_from_file()
|
||||
age = self.age_sec()
|
||||
if self._approval_key and age < max_valid:
|
||||
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:
|
||||
@@ -387,11 +429,11 @@ class KISApprovalManager:
|
||||
)
|
||||
return key
|
||||
finally:
|
||||
_release_lock()
|
||||
_release_lock(self._lock_path)
|
||||
|
||||
|
||||
def get_approval_status(is_mock: bool) -> dict:
|
||||
mgr = KISApprovalManager.instance(is_mock)
|
||||
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
|
||||
@@ -401,4 +443,5 @@ def get_approval_status(is_mock: bool) -> dict:
|
||||
"key_prefix": (mgr._approval_key[:8] + "…") if mgr._approval_key else "",
|
||||
"age_h": age_h,
|
||||
"file": str(mgr._cache_path.name),
|
||||
"slot": mgr._slot,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user