feat: 새로운 안전 규칙 및 최적화 적용을 통한 트레이딩 시스템 개선
변경 사항 (Changes): 구문 오류(Syntax error) 및 토큰 낭비를 방지하기 위해 에이전트 쉘(Agent shell)과 파이썬 코드 스니펫에 다수의 신규 안전 규칙(Safety rules)을 추가함. 스키마 검증 및 적절한 SQL 포맷팅을 보장하기 위해 임시(Ad-hoc) 데이터베이스 쿼리 작성 가이드라인을 도입함. 코드 수정 후 UI 기능이 정상 작동하는지 확인하기 위해, 백테스트 웹 서비스 재시작 및 브라우저 검증에 대한 새로운 규칙을 구현함. 시스템 전반의 무결성(Integrity)을 유지하기 위해 실전 매매(Live trading), 웹 백테스팅, 파라미터 탐색(Parameter searches) 간의 일관성 검사(Consistency checks) 체계를 확립함. 기대 효과 (Impact): 이러한 개선 사항들은 트레이딩 시스템의 견고성(Robustness)과 신뢰성을 향상시키며, 에러 발생을 최소화하고 다양한 시스템 컴포넌트 간의 원활한 상호작용을 보장함.
This commit is contained in:
277
kis_approval_manager.py
Normal file
277
kis_approval_manager.py
Normal file
@@ -0,0 +1,277 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
kis_approval_manager.py — KIS WebSocket approval_key 파일 캐시 (국내·해외 WS 공유)
|
||||
==================================================================================
|
||||
REST access_token(.kis_token_cache_*.json) 과 동일 패턴:
|
||||
- 프로세스·재시작·국내/해외 WS 가 **하나의 approval_key** 를 공유
|
||||
- KIS 정책: 24h 유효, 6h 이내 REST 재발급 금지
|
||||
- 파일 잠금으로 동시 발급(국내+해외·재시작) 경합 방지
|
||||
|
||||
문제(기존):
|
||||
- kis_ws / kis_ws_overseas 가 각각 /oauth2/Approval 호출 → 나중 키가 앞 키 무효화
|
||||
- 메모리만 사용 → 재시작마다 새 발급 → invalid approval 루프
|
||||
"""
|
||||
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"
|
||||
LOCK_FILE = ROOT / ".kis_approval_manager.lock"
|
||||
LOCK_TIMEOUT_S = 60
|
||||
|
||||
DEFAULT_MIN_REISSUE_SEC = 21600.0
|
||||
DEFAULT_MAX_VALID_SEC = 82800.0
|
||||
|
||||
_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 _acquire_lock() -> 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)
|
||||
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)
|
||||
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() -> None:
|
||||
try:
|
||||
LOCK_FILE.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 싱글톤 — 실전/모의 각 1개."""
|
||||
|
||||
@classmethod
|
||||
def instance(cls, is_mock: bool) -> "KISApprovalManager":
|
||||
with _am_instances_lock:
|
||||
if is_mock not in _am_instances:
|
||||
_am_instances[is_mock] = cls(is_mock)
|
||||
return _am_instances[is_mock]
|
||||
|
||||
def __init__(self, is_mock: bool) -> 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._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,
|
||||
},
|
||||
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
|
||||
|
||||
@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,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
유효한 approval_key 반환.
|
||||
- 24h 이내: 파일/메모리 캐시 재사용 (재연결 포함)
|
||||
- 6h 미경과: force_refresh=True 여도 REST 재발급 금지
|
||||
- 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 age >= min_reissue:
|
||||
pass
|
||||
elif force_refresh and age < 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():
|
||||
with self._lock:
|
||||
self._load_from_file()
|
||||
return self._approval_key
|
||||
|
||||
try:
|
||||
with self._lock:
|
||||
self._load_from_file()
|
||||
age = self.age_sec()
|
||||
if self._approval_key and age < max_valid:
|
||||
if not force_refresh or age < 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)",
|
||||
self._mode_str,
|
||||
key[:8],
|
||||
min_reissue / 3600,
|
||||
self._cache_path.name,
|
||||
)
|
||||
return key
|
||||
finally:
|
||||
_release_lock()
|
||||
|
||||
|
||||
def get_approval_status(is_mock: bool) -> dict:
|
||||
mgr = KISApprovalManager.instance(is_mock)
|
||||
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),
|
||||
}
|
||||
Reference in New Issue
Block a user