커서가 망쳐놓은 듯

This commit is contained in:
2026-03-17 12:33:30 +09:00
parent 6fc179f598
commit c2b2b711e0
91 changed files with 45391 additions and 2244 deletions

View File

@@ -8,7 +8,7 @@ KIS Long Trading Bot Ver1 - 늘림목 전략용 한투 API 트레이딩 시스
import os
import json
import ㅁㅁ
import time
import random
import logging
import datetime
@@ -52,9 +52,9 @@ except ImportError:
ML_AVAILABLE = False
logger.warning("⚠️ ml_predictor 미설치 - ML 예측 기능 사용 불가")
# DB 초기화
# DB 초기화 — MariaDB 192.168.0.141 (database.py 모듈 상수 사용)
SCRIPT_DIR = Path(__file__).resolve().parent
db = TradeDB(db_path=str(SCRIPT_DIR / "quant_bot.db"))
db = TradeDB() # db_path 인수 무시됨, MariaDB 직접 연결
# DB에서 환경변수 로드 (초기 버전: Mattermost/Gemini 설정용)
def get_env_from_db(key, default=""):
@@ -153,6 +153,43 @@ def _save_kis_token_cache(access_token, access_token_token_expired, mock):
pass
def _invalidate_kis_token_cache(mock: bool = False):
"""
토큰 만료(EGW00123 등) 감지 시 캐시 무효화 → 다음 _headers()에서 자동 재발급.
1. KisTokenManager 메모리 캐시 초기화
2. 레거시 파일 캐시 삭제 (폴백용)
"""
try:
from kis_token_manager import KisTokenManager, _km_instances, _km_instances_lock
import threading
with _km_instances_lock:
if mock in _km_instances:
inst = _km_instances[mock]
inst._token = None
inst._expiry = None
logger.info("한투 토큰 메모리 캐시 초기화 (만료 감지) [%s]",
"모의" if mock else "실전")
except Exception as e:
logger.debug("KisTokenManager 초기화 실패: %s", e)
# 레거시 파일도 삭제
try:
if KIS_TOKEN_CACHE_PATH.exists():
KIS_TOKEN_CACHE_PATH.unlink()
logger.info("한투 토큰 레거시 캐시 삭제: %s", KIS_TOKEN_CACHE_PATH)
except Exception as e:
logger.warning("한투 토큰 캐시 삭제 실패(%s): %s", KIS_TOKEN_CACHE_PATH, e)
def _is_token_expired_response(j: dict) -> bool:
"""응답이 '기간이 만료된 token' 오류(EGW00123 등)인지 여부."""
if not j or not isinstance(j, dict):
return False
msg_cd = j.get("msg_cd") or ""
msg1 = str(j.get("msg1", "") or "")
# EGW00123 외에도 msg1 문자열에 '만료된 token' / '만료' 가 포함되어 있으면 토큰 만료로 간주
return msg_cd == "EGW00123" or "만료된 token" in msg1 or "만료" in msg1
class KISClientWithOrder:
"""주문 기능이 추가된 KIS 클라이언트 (env 키는 단타 봇과 동일: KIS_APP_KEY_MOCK/REAL, KIS_ACCOUNT_NO_MOCK/REAL)"""
def __init__(self, mock=None):
@@ -213,32 +250,46 @@ class KISClientWithOrder:
self._auth()
def _auth(self):
"""접근 토큰 발급"""
"""
접근 토큰 준비 — KisTokenManager 싱글톤 우선 사용.
- 메모리 캐시 → 파일 캐시 → 신규 발급 순서로 처리
- 만료 10분 전 선제 갱신 (KIS 23시간 정책 자동 준수)
"""
if not self.app_key or not self.app_secret:
hint = "KIS_APP_KEY_MOCK, KIS_APP_SECRET_MOCK" if self.mock else "KIS_APP_KEY_REAL, KIS_APP_SECRET_REAL (또는 KIS_APP_KEY, KIS_APP_SECRET)"
hint = "KIS_APP_KEY_MOCK, KIS_APP_SECRET_MOCK" if self.mock else "KIS_APP_KEY_REAL, KIS_APP_SECRET_REAL"
raise ValueError(f"한투 앱키 미설정: {hint}")
mode_str = "모의" if self.mock else "실전"
try:
from kis_token_manager import KisTokenManager
token = KisTokenManager.instance(is_mock=self.mock).get_token()
if token:
self.access_token = token
logger.info("✅ 한투 토큰 준비 완료 [%s] (KisTokenManager, 앞8자: %s…)",
mode_str, token[:8])
return
except Exception as e:
logger.debug("KisTokenManager 실패, 파일 캐시 폴백: %s", e)
# 폴백: 기존 파일 캐시
cached = _load_kis_token_cache(self.mock)
if cached:
self.access_token = cached
logger.info("한투 토큰 캐시 사용 (%s)", "모의" if self.mock else "실전")
logger.info("한투 토큰 파일 캐시 사용 [%s]", mode_str)
return
url = f"{self.base_url}/oauth2/tokenP"
body = {"grant_type": "client_credentials", "appkey": self.app_key, "appsecret": self.app_secret}
# 폴백: kis_token_manager 경로로만 발급 (잠금·1일1회 준수, SMS 시각 안정화)
try:
r = requests.post(url, json=body, timeout=10)
data = r.json()
if "access_token" in data:
self.access_token = data["access_token"]
expired = data.get("access_token_token_expired") or ""
_save_kis_token_cache(self.access_token, expired, self.mock)
logger.info("한투 토큰 발급 완료 (%s)", "모의" if self.mock else "실전")
else:
raise RuntimeError("한투 토큰 발급 실패")
from kis_token_manager import ensure_token
if ensure_token(self.mock):
cached = _load_kis_token_cache(self.mock)
if cached:
self.access_token = cached
logger.info("✅ 한투 토큰 발급 완료 [%s]", mode_str)
return
except Exception as e:
logger.error("한투 인증 예외: %s", e)
raise
logger.warning("kis_token_manager 발급 실패: %s", e)
raise RuntimeError("한투 토큰 발급 실패")
def _get_hashkey(self, body):
"""해시키(Hashkey) 발급 - POST 요청 시 body 무결성 검증용"""
@@ -260,7 +311,19 @@ class KISClientWithOrder:
return None
def _headers(self, tr_id, hashkey=None):
"""API 호출용 헤더"""
"""
API 호출용 헤더.
KisTokenManager.get_token() 으로 만료 10분 전 선제 갱신:
- 유효하면 메모리에서 즉시 반환 (오버헤드 없음)
- 만료 임박 시 자동 갱신 후 새 토큰 사용 → EGW00123 방지
"""
try:
from kis_token_manager import KisTokenManager
fresh = KisTokenManager.instance(is_mock=self.mock).get_token()
if fresh:
self.access_token = fresh
except Exception:
pass # 실패 시 기존 access_token 유지
headers = {
"content-type": "application/json; charset=utf-8",
"authorization": f"Bearer {self.access_token}",
@@ -272,24 +335,39 @@ class KISClientWithOrder:
headers["hashkey"] = hashkey
return headers
def _get(self, path, tr_id, params, max_retries=3):
"""GET 요청. 429 시 지수 백오프 재시도"""
def _get(self, path, tr_id, params, max_retries=5):
"""GET 요청. 429/500+EGW00201(초당 거래건수 초과) 시 대기 후 재시도, 토큰 만료 시 캐시 삭제 후 1회 재인증."""
url = f"{self.base_url}{path}"
token_refreshed = False
for attempt in range(max_retries):
try:
r = requests.get(url, headers=self._headers(tr_id), params=params, timeout=15)
if r.status_code == 429:
wait_time = (2 ** attempt) + random.uniform(0.5, 1.5)
logger.warning(f"⏳ API 호출 제한 (429) -> {wait_time:.1f}초 대기 후 재시도 ({attempt+1}/{max_retries})")
wait_time = 1.5 + (attempt * 1.0)
logger.warning(f"⏳ API 호출 제한 (429) -> {wait_time:.1f}초 대기 후 재시도 ({attempt+1}/{max_retries}) path={path}")
time.sleep(wait_time)
continue
if r.status_code == 200:
j = r.json()
if j.get("rt_cd") == "0":
# 200 또는 500(한투가 EGW00201 시 500 반환) 응답에서 body 파싱
if r.status_code in (200, 500):
try:
j = r.json()
except Exception:
j = {}
if r.status_code == 200 and j.get("rt_cd") == "0":
return r
elif "초과" in str(j.get("msg1", "")) or "과부하" in str(j.get("msg1", "")):
wait_time = (2 ** attempt) + random.uniform(0.5, 1.5)
logger.warning(f"⏳ API 과부하 -> {wait_time:.1f}초 대기 후 재시도")
if _is_token_expired_response(j) and not token_refreshed:
logger.warning("🔑 한투 토큰 만료 감지(EGW00123 등) → 캐시 무효화 후 재인증, GET 재시도")
_invalidate_kis_token_cache(mock=self.mock)
self._auth()
token_refreshed = True
time.sleep(0.5)
continue
# EGW00201 또는 msg1에 '초과'/'과부하' → 초당 거래건수 초과, 대기 후 재시도
if j.get("msg_cd") == "EGW00201" or "초과" in str(j.get("msg1", "")) or "과부하" in str(j.get("msg1", "")):
wait_time = 1.5 + (attempt * 1.0)
logger.warning(
f"⏳ API 초당거래 초과 (EGW00201) GET {path} -> {wait_time:.1f}초 대기 후 재시도 ({attempt+1}/{max_retries}) msg1={j.get('msg1', '')}"
)
time.sleep(wait_time)
continue
return r
@@ -456,33 +534,48 @@ class KISClientWithOrder:
except Exception:
return None
def _post(self, path, tr_id, body, use_hashkey=True, max_retries=3):
"""POST 요청. 해시키 사용 및 429 시 지수 백오프 재시도"""
def _post(self, path, tr_id, body, use_hashkey=True, max_retries=5):
"""POST 요청. 429/500+EGW00201 시 대기 후 재시도, 토큰 만료 시 캐시 삭제 후 1회 재인증."""
url = f"{self.base_url}{path}"
hashkey = None
if use_hashkey:
hashkey = self._get_hashkey(body)
if not hashkey:
logger.debug("해시키 발급 실패, 해시키 없이 진행")
token_refreshed = False
for attempt in range(max_retries):
try:
r = requests.post(url, headers=self._headers(tr_id, hashkey), json=body, timeout=15)
if r.status_code == 429:
wait_time = (2 ** attempt) + random.uniform(0.5, 1.5)
logger.warning(f"⏳ API 호출 제한 (429) -> {wait_time:.1f}초 대기 후 재시도 ({attempt+1}/{max_retries})")
wait_time = 1.5 + (attempt * 1.0)
logger.warning(f"⏳ API 호출 제한 (429) -> {wait_time:.1f}초 대기 후 재시도 ({attempt+1}/{max_retries}) path={path}")
time.sleep(wait_time)
if use_hashkey:
hashkey = self._get_hashkey(body)
continue
if r.status_code == 200:
j = r.json()
if j.get("rt_cd") == "0":
if r.status_code in (200, 500):
try:
j = r.json()
except Exception:
j = {}
if r.status_code == 200 and j.get("rt_cd") == "0":
return r
elif "초과" in str(j.get("msg1", "")) or "과부하" in str(j.get("msg1", "")):
wait_time = (2 ** attempt) + random.uniform(0.5, 1.5)
logger.warning(f"⏳ API 과부하 -> {wait_time:.1f}초 대기 후 재시도")
if _is_token_expired_response(j) and not token_refreshed:
logger.warning("🔑 한투 토큰 만료 감지(EGW00123 등) → 캐시 무효화 후 재인증, POST 재시도")
_invalidate_kis_token_cache(mock=self.mock)
self._auth()
token_refreshed = True
if use_hashkey:
hashkey = self._get_hashkey(body)
time.sleep(0.5)
continue
if j.get("msg_cd") == "EGW00201" or "초과" in str(j.get("msg1", "")) or "과부하" in str(j.get("msg1", "")):
wait_time = 1.5 + (attempt * 1.0)
logger.warning(
f"⏳ API 초당거래 초과 (EGW00201) POST {path} -> {wait_time:.1f}초 대기 후 재시도 ({attempt+1}/{max_retries}) msg1={j.get('msg1', '')}"
)
time.sleep(wait_time)
if use_hashkey:
hashkey = self._get_hashkey(body)