refactor: enhance Optuna backtesting framework, optimize orderbook filtering, and update database management utilities.
This commit is contained in:
@@ -4,21 +4,26 @@ kis_token_manager.py — KIS 실전/모의 토큰 통합 자동 관리
|
||||
==========================================================
|
||||
문제: 모의 모드로만 봇을 돌리면 실전 토큰이 갱신되지 않음.
|
||||
→ 실전 토큰 만료 → WebSocket/홀딩봇 REST 호출 실패 → 거래 없음
|
||||
→ 20h 표류 타이머 + 장중 10분 선제 갱신으로 발급 시각이 장중에 밀림
|
||||
|
||||
해결: 어느 모드로 봇을 실행해도 실전+모의 토큰 모두 유효하게 유지.
|
||||
파일 잠금(lockfile) 으로 동시에 여러 봇이 실행해도 중복 발급 방지.
|
||||
해결:
|
||||
- 매일 장전(systemd OnCalendar 08:00)에 ensure_both_tokens()
|
||||
- 실전+모의 둘 다: **오늘 세션을 못 덮거나 이미 만료일 때만** 발급
|
||||
- 장중 get_token(): **완전 만료(±60초)일 때만** 비상 발급 (선제 SMS 금지)
|
||||
|
||||
KIS 제한:
|
||||
- 하루 1회 원칙 (23시간 내 중복 발급 시 제한 가능)
|
||||
- 이 모듈은 만료 1시간 전까지 기존 토큰 재사용 → 하루 1회만 발급
|
||||
KIS 접근토큰(/oauth2/tokenP) 한도 (우선 준수):
|
||||
- 유효 약 24시간
|
||||
- 1일 1회 발급 원칙 (유효기간 내 잦은 발급 → 이용 제한 가능)
|
||||
- EGW00133: 1분당 1회
|
||||
※ WS approval_key 의 6h 재발급 가드는 별도(kis_approval_manager) — 여기서 건드리지 않음
|
||||
|
||||
사용 예시 (각 봇 시작부):
|
||||
from kis_token_manager import ensure_both_tokens
|
||||
ensure_both_tokens() # 실전+모의 토큰 자동 갱신 (만료된 것만)
|
||||
ensure_both_tokens() # 실전+모의: 세션 미커버분만 갱신
|
||||
|
||||
CLI:
|
||||
python3 kis_token_manager.py # 상태 확인
|
||||
python3 kis_token_manager.py --refresh # 강제 갱신 시도
|
||||
python3 kis_token_manager.py # 상태 확인 + 필요 시만 갱신
|
||||
python3 kis_token_manager.py --refresh # 강제 갱신 시도 (한도 주의)
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -38,8 +43,10 @@ ROOT = Path(__file__).parent
|
||||
CACHE_MOCK = ROOT / ".kis_token_cache_mock.json"
|
||||
CACHE_REAL = ROOT / ".kis_token_cache_real.json"
|
||||
LOCK_FILE = ROOT / ".kis_token_manager.lock"
|
||||
EXPIRE_MARGIN_H = 1 # 파일 캐시 상태 조회용 (get_token_status, ensure_token) 버퍼
|
||||
EXPIRE_MARGIN_M = 10 # KisTokenManager.get_token() 선제 갱신 버퍼 (10분)
|
||||
# 장중 비상: 만료 N초 전부터만 재발급 시도 (옛 10분 선제 = 장중 카톡 유발 → 폐기)
|
||||
EXPIRE_SAFETY_SEC = 60
|
||||
# 오늘 정규장+버퍼를 덮는지 판정 (HHMM). 이 시각 전에 만료면 장전 발급.
|
||||
SESSION_END_HHMM = 1600
|
||||
LOCK_TIMEOUT_S = 60 # 잠금 최대 대기 시간(초)
|
||||
|
||||
|
||||
@@ -71,33 +78,99 @@ def _parse_expired(s: str):
|
||||
return None
|
||||
|
||||
|
||||
def _session_end_hhmm() -> int:
|
||||
"""오늘 세션 커버 목표 HHMM (기본 1600). OS env 로만 오버라이드."""
|
||||
raw = (os.environ.get("KIS_TOKEN_SESSION_END_HHMM", "") or "").strip()
|
||||
if not raw:
|
||||
return SESSION_END_HHMM
|
||||
try:
|
||||
v = int(raw)
|
||||
if 0 <= v <= 2359:
|
||||
return v
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return SESSION_END_HHMM
|
||||
|
||||
|
||||
def _session_cover_deadline(now: Optional[datetime] = None) -> datetime:
|
||||
"""당일 세션 커버 데드라인 (시:분)."""
|
||||
now = now or datetime.now()
|
||||
hhmm = _session_end_hhmm()
|
||||
return now.replace(
|
||||
hour=hhmm // 100,
|
||||
minute=hhmm % 100,
|
||||
second=0,
|
||||
microsecond=0,
|
||||
)
|
||||
|
||||
|
||||
def token_covers_session(
|
||||
exp_dt: Optional[datetime],
|
||||
now: Optional[datetime] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
접근토큰이 '오늘 운용'을 덮는지.
|
||||
- 이미 만료 → False (발급 필요)
|
||||
- 지금이 세션 종료 이전이면: 만료시각 >= 세션종료 이어야 True
|
||||
- 세션 종료 이후(장후)면: 아직 만료 전이면 True (불필요 재발급 금지)
|
||||
"""
|
||||
now = now or datetime.now()
|
||||
if exp_dt is None:
|
||||
return False
|
||||
if now >= exp_dt:
|
||||
return False
|
||||
deadline = _session_cover_deadline(now)
|
||||
if now >= deadline:
|
||||
return True
|
||||
return exp_dt >= deadline
|
||||
|
||||
|
||||
def get_token_status(is_mock: bool) -> dict:
|
||||
"""
|
||||
캐시 파일 상태 반환.
|
||||
반환: {"valid": bool, "token": str, "expires": str, "expires_in_h": float}
|
||||
반환: valid=세션커버(ensure 재사용 기준), usable=만료 전 API 사용 가능
|
||||
"""
|
||||
cache_path = CACHE_MOCK if is_mock else CACHE_REAL
|
||||
mode = "모의" if is_mock else "실전"
|
||||
if not cache_path.exists():
|
||||
return {"valid": False, "token": "", "expires": "파일없음", "expires_in_h": -999}
|
||||
return {
|
||||
"valid": False,
|
||||
"usable": False,
|
||||
"token": "",
|
||||
"expires": "파일없음",
|
||||
"expires_in_h": -999,
|
||||
}
|
||||
try:
|
||||
cache = json.loads(cache_path.read_text(encoding="utf-8"))
|
||||
token = cache.get("access_token", "")
|
||||
expired_s = cache.get("access_token_token_expired", "")
|
||||
exp_dt = _parse_expired(expired_s)
|
||||
if not token or exp_dt is None:
|
||||
return {"valid": False, "token": "", "expires": expired_s, "expires_in_h": -999}
|
||||
return {
|
||||
"valid": False,
|
||||
"usable": False,
|
||||
"token": "",
|
||||
"expires": expired_s,
|
||||
"expires_in_h": -999,
|
||||
}
|
||||
now = datetime.now()
|
||||
expires_in = (exp_dt - now).total_seconds() / 3600
|
||||
valid = now < exp_dt - timedelta(hours=EXPIRE_MARGIN_H)
|
||||
usable = now < exp_dt
|
||||
covers = token_covers_session(exp_dt, now)
|
||||
return {
|
||||
"valid": valid,
|
||||
"token": token[:12] + "…",
|
||||
"expires": expired_s,
|
||||
"valid": covers,
|
||||
"usable": usable,
|
||||
"token": token[:12] + "…",
|
||||
"expires": expired_s,
|
||||
"expires_in_h": round(expires_in, 1),
|
||||
}
|
||||
except Exception as e:
|
||||
return {"valid": False, "token": "", "expires": f"읽기오류: {e}", "expires_in_h": -999}
|
||||
return {
|
||||
"valid": False,
|
||||
"usable": False,
|
||||
"token": "",
|
||||
"expires": f"읽기오류: {e}",
|
||||
"expires_in_h": -999,
|
||||
}
|
||||
|
||||
|
||||
def _acquire_lock() -> bool:
|
||||
@@ -175,8 +248,8 @@ def _issue_token(app_key: str, app_secret: str, is_mock: bool) -> bool:
|
||||
KIS /oauth2/tokenP 엔드포인트로 새 토큰 발급 후 캐시 저장.
|
||||
성공 시 True, 실패 시 False.
|
||||
|
||||
※ KIS 정책: 23시간 내 중복 발급 시 서비스 제한 가능 + 분당 1회 제한 (EGW00133).
|
||||
이 함수는 만료 1시간 전까지 기존 토큰을 재사용하므로 하루 1회만 호출됨.
|
||||
※ KIS 정책: 1일 1회 원칙 + 분당 1회 제한 (EGW00133).
|
||||
ensure_token 이 세션 미커버/만료일 때만 여기로 온다.
|
||||
발급 실패 시 백오프(`_set_backoff`)로 호출자 폭주 방지.
|
||||
"""
|
||||
# 백오프 미경과 → 즉시 실패 (호출자에게 재시도 부담 떠넘기기 금지)
|
||||
@@ -226,6 +299,7 @@ def _issue_token(app_key: str, app_secret: str, is_mock: bool) -> bool:
|
||||
"access_token": token,
|
||||
"access_token_token_expired": exp,
|
||||
"mock": is_mock,
|
||||
"issued_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
@@ -245,19 +319,23 @@ def _issue_token(app_key: str, app_secret: str, is_mock: bool) -> bool:
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def ensure_token(is_mock: bool, env: dict = None) -> bool:
|
||||
"""
|
||||
단일 모드(실전/모의) 토큰 유효성 확인 + 필요 시 자동 갱신.
|
||||
이미 유효한 경우 → 발급 없이 True 반환 (23시간 정책 자동 준수).
|
||||
단일 모드(실전/모의) 토큰: 오늘 세션을 덮으면 재사용, 아니면만 발급.
|
||||
1일 1회 원칙 — 충분하면 tokenP 호출 없음.
|
||||
"""
|
||||
status = get_token_status(is_mock)
|
||||
mode = "모의" if is_mock else "실전"
|
||||
|
||||
if status["valid"]:
|
||||
logger.info(
|
||||
f"🔑 {mode} 토큰 유효 ({status['expires_in_h']:.1f}h 남음) → 재사용"
|
||||
f"🔑 {mode} 토큰 세션커버 OK ({status['expires_in_h']:.1f}h 남음, "
|
||||
f"만료 {status['expires']}) → 재사용 (발급 안 함)"
|
||||
)
|
||||
return True
|
||||
|
||||
logger.info(f"⏰ {mode} 토큰 만료/없음 → 갱신 시도 (남은시간: {status['expires_in_h']:.1f}h)")
|
||||
logger.info(
|
||||
f"⏰ {mode} 토큰 세션미커버/만료 → 갱신 시도 "
|
||||
f"(남은시간: {status['expires_in_h']:.1f}h, 만료 {status['expires']})"
|
||||
)
|
||||
|
||||
if env is None:
|
||||
env = _load_env()
|
||||
@@ -285,10 +363,10 @@ def ensure_token(is_mock: bool, env: dict = None) -> bool:
|
||||
|
||||
def ensure_both_tokens(env: dict = None) -> dict:
|
||||
"""
|
||||
실전 + 모의 토큰 모두 유효하게 유지.
|
||||
실전 + 모의 토큰 모두 세션커버 유지.
|
||||
|
||||
어느 모드로 봇을 실행하든 두 토큰을 동시에 관리.
|
||||
만료된 것만 갱신 → KIS 23시간 정책 자동 준수.
|
||||
어느 모드로 봇을 실행하든 두 토큰을 동시에 점검.
|
||||
세션 미커버/만료분만 발급 → KIS 1일 1회 원칙 준수.
|
||||
|
||||
반환: {"real": True/False, "mock": True/False}
|
||||
"""
|
||||
@@ -315,7 +393,7 @@ def ensure_both_tokens(env: dict = None) -> dict:
|
||||
# 동작 원리:
|
||||
# 1. 메모리 캐시 (_token, _expiry) — 파일 읽기 최소화
|
||||
# 2. JSON 파일 캐시 — 크로스 프로세스 공유 + 재시작 후 토큰 유지
|
||||
# 3. 만료 10분 전 선제 갱신 — API 에러(EGW00123) 없이 자동 교체
|
||||
# 3. 만료(±60초) 시에만 비상 발급 — 장중 10분 선제 발급 금지
|
||||
#
|
||||
# KIS 에는 토큰 상태 조회 API 가 없음 → 발급 시 받은 expires_dt 를
|
||||
# 서버 현재시간과 비교해서 판단 (JSON 파일에 저장된 날짜 사용)
|
||||
@@ -331,7 +409,7 @@ _km_instances_lock = threading.Lock()
|
||||
class KisTokenManager:
|
||||
"""
|
||||
KIS 토큰 싱글톤 매니저.
|
||||
- get_token(): 유효하면 바로 반환, 만료 10분 전부터 선제 갱신
|
||||
- get_token(): 유효하면 바로 반환, 완전 만료 시에만 비상 갱신
|
||||
- kiwoom_rest_api TokenManager / KiwoomTokenManager 와 동일한 인터페이스
|
||||
"""
|
||||
|
||||
@@ -369,18 +447,18 @@ class KisTokenManager:
|
||||
|
||||
def _is_valid(self) -> bool:
|
||||
"""
|
||||
만료 10분 전부터 유효하지 않다고 판단 → 선제 갱신 트리거.
|
||||
KIS에는 토큰 상태 조회 API 없음 → JSON 파일의 expires_dt 와 서버시간 비교.
|
||||
API 사용 가능 여부. 장중 선제 발급 트리거용 10분 마진 없음.
|
||||
만료 SAFETY초 전까지만 유효 → 그 전에는 tokenP 안 침.
|
||||
"""
|
||||
if not self._token or not self._expiry:
|
||||
return False
|
||||
return datetime.now() < self._expiry - timedelta(minutes=EXPIRE_MARGIN_M)
|
||||
return datetime.now() < self._expiry - timedelta(seconds=EXPIRE_SAFETY_SEC)
|
||||
|
||||
# ── 공개 ──────────────────────────────────────────────────────
|
||||
def get_token(self) -> Optional[str]:
|
||||
"""
|
||||
유효한 토큰 반환. 만료 10분 전 자동 갱신.
|
||||
매수/매도 API 호출 직전마다 호출해도 안전 (갱신은 만료 임박 시만).
|
||||
유효한 토큰 반환. 완전 만료(±60초) 시에만 비상 갱신.
|
||||
평소 발급은 장전 ensure_both_tokens / 타이머가 담당.
|
||||
|
||||
흐름:
|
||||
1. 메모리 캐시 유효 → 즉시 반환 (파일 읽기 없음)
|
||||
@@ -408,13 +486,13 @@ class KisTokenManager:
|
||||
# 만료 토큰이라도 반환 (None 보다 안전 — 호출자 None 폭주 방지)
|
||||
return self._token
|
||||
|
||||
# 파일도 만료 → 잠금 기반 신규 발급
|
||||
# 파일도 만료 → 잠금 기반 신규 발급 (ensure 는 세션커버 판정)
|
||||
ok = ensure_token(self._is_mock)
|
||||
if ok:
|
||||
with self._lock:
|
||||
self._load_from_file()
|
||||
logger.info(
|
||||
"🔄 %s 토큰 선제 갱신 완료 (만료 10분 전) → 앞8자: %s…",
|
||||
"🔄 %s 토큰 비상 갱신 완료 (만료/세션미커버) → 앞8자: %s…",
|
||||
self._mode_str,
|
||||
(self._token[:8] if self._token else "N/A"),
|
||||
)
|
||||
@@ -442,15 +520,26 @@ if __name__ == "__main__":
|
||||
)
|
||||
|
||||
parser = argparse.ArgumentParser(description="KIS 토큰 관리자")
|
||||
parser.add_argument("--refresh", action="store_true", help="만료 여부와 관계없이 강제 갱신 시도")
|
||||
parser.add_argument(
|
||||
"--refresh",
|
||||
action="store_true",
|
||||
help="캐시 삭제 후 강제 갱신 (1일1회 한도 주의 — 평소 사용 금지)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
_dl = _session_cover_deadline()
|
||||
print("\n" + "=" * 55)
|
||||
print(" KIS 토큰 상태")
|
||||
print(f" 세션커버 기준: 당일 {_dl.strftime('%H:%M')} 까지 유효해야 재사용")
|
||||
print("=" * 55)
|
||||
for is_mock, label in [(False, "실전"), (True, "모의")]:
|
||||
s = get_token_status(is_mock)
|
||||
flag = "✅ 유효" if s["valid"] else "❌ 만료/없음"
|
||||
if s["valid"]:
|
||||
flag = "✅ 세션커버"
|
||||
elif s.get("usable"):
|
||||
flag = "⚠️ 사용가능·세션미커버"
|
||||
else:
|
||||
flag = "❌ 만료/없음"
|
||||
print(f" {label:4s} | {flag} | 만료: {s['expires']} ({s['expires_in_h']:+.1f}h)")
|
||||
print("=" * 55)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user