main_server.py가 중복 실행되면서 좀비 프로세스가 수행되는 오류 해결, process_tracker.py가 감시하면서 할당되지 않은 pid가 존재하면 좀비프로세스로 판단하여 kill
This commit is contained in:
@@ -28,6 +28,19 @@ class KISClient:
|
||||
self.access_token = None
|
||||
self.token_expired = None
|
||||
self.last_req_time = 0
|
||||
|
||||
# 토큰 파일 경로 (영구 저장용)
|
||||
self.token_file = os.path.join(Config.DATA_DIR, "kis_token.json")
|
||||
self.load_token() # 초기화 시 토큰 로드 시도
|
||||
|
||||
def _safe_int(self, val):
|
||||
"""안전한 int 변환"""
|
||||
try:
|
||||
if not val:
|
||||
return 0
|
||||
return int(str(val).strip())
|
||||
except:
|
||||
return 0
|
||||
|
||||
def _throttle(self):
|
||||
"""API 요청 속도 제한 (초당 2회 이하로 제한)"""
|
||||
@@ -41,6 +54,38 @@ class KISClient:
|
||||
|
||||
self.last_req_time = time.time()
|
||||
|
||||
def load_token(self):
|
||||
"""파일에서 토큰 로드"""
|
||||
if os.path.exists(self.token_file):
|
||||
try:
|
||||
with open(self.token_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
# 만료 시간 체크
|
||||
expire_str = data.get("expired_at")
|
||||
if expire_str:
|
||||
expire_dt = datetime.strptime(expire_str, "%Y-%m-%d %H:%M:%S")
|
||||
if datetime.now() < expire_dt:
|
||||
self.access_token = data.get("access_token")
|
||||
self.token_expired = expire_dt
|
||||
print(f"📂 [KIS] Saved Token Loaded (Expires: {expire_str})")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Failed to load token file: {e}")
|
||||
|
||||
def save_token(self):
|
||||
"""토큰 파일 저장"""
|
||||
if not self.access_token or not self.token_expired:
|
||||
return
|
||||
|
||||
try:
|
||||
data = {
|
||||
"access_token": self.access_token,
|
||||
"expired_at": self.token_expired.strftime("%Y-%m-%d %H:%M:%S")
|
||||
}
|
||||
with open(self.token_file, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
except Exception as e:
|
||||
print(f"⚠️ Failed to save token file: {e}")
|
||||
|
||||
def _get_headers(self, tr_id=None):
|
||||
"""공통 헤더 생성"""
|
||||
headers = {
|
||||
@@ -54,10 +99,16 @@ class KISClient:
|
||||
|
||||
return headers
|
||||
|
||||
def ensure_token(self):
|
||||
"""접근 토큰 발급 (OAuth 2.0)"""
|
||||
# 토큰 유효성 체크 로직은 생략 (실제 운영 시 만료 시간 체크 필요)
|
||||
if self.access_token:
|
||||
def ensure_token(self, force=False):
|
||||
"""접근 토큰 발급 (OAuth 2.0) 및 유효성 관리"""
|
||||
# 토큰이 있고, 만료 시간이 아직 안 지났으면 재사용
|
||||
if not force and self.access_token and self.token_expired:
|
||||
if datetime.now() < self.token_expired:
|
||||
return
|
||||
|
||||
# 앱키 확인
|
||||
if not self.app_key or not self.app_secret:
|
||||
print("❌ [KIS] App Key or Secret is missing!")
|
||||
return
|
||||
|
||||
url = f"{self.base_url}/oauth2/tokenP"
|
||||
@@ -68,16 +119,41 @@ class KISClient:
|
||||
}
|
||||
|
||||
try:
|
||||
print("🔑 [KIS] 토큰 발급 요청...")
|
||||
print(f"🔑 [KIS] 토큰 발급 요청: {url}")
|
||||
res = requests.post(url, json=payload)
|
||||
res.raise_for_status()
|
||||
data = res.json()
|
||||
|
||||
self.access_token = data.get('access_token')
|
||||
print("✅ [KIS] 토큰 발급 성공")
|
||||
|
||||
# 만료 시간 설정
|
||||
expires_in = int(data.get('expires_in', 86400))
|
||||
self.token_expired = datetime.now() + timedelta(seconds=expires_in - 60)
|
||||
|
||||
# 파일 저장
|
||||
self.save_token()
|
||||
|
||||
print(f"✅ [KIS] 토큰 발급 성공 (만료: {self.token_expired.strftime('%Y-%m-%d %H:%M:%S')})")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ [KIS] 토큰 발급 실패: {e}")
|
||||
# 1분 제한 에러 핸들링 (EGW00133)
|
||||
retry = False
|
||||
if isinstance(e, requests.exceptions.RequestException) and e.response is not None:
|
||||
print(f"📄 [KIS Token Error Body]: {e.response.text}")
|
||||
err_text = e.response.text
|
||||
print(f"📄 [KIS Error]: {err_text}")
|
||||
if "EGW00133" in err_text:
|
||||
print("⏳ [KIS] Rate Limit Hit (1 min). Waiting 65s...")
|
||||
time.sleep(65) # 1분 대기
|
||||
retry = True
|
||||
|
||||
if retry:
|
||||
# 재귀 호출 (한 번만)
|
||||
self.ensure_token()
|
||||
return
|
||||
|
||||
print(f"❌ [KIS] 토큰 발급 실패: {e}")
|
||||
self.access_token = None
|
||||
raise e
|
||||
|
||||
def get_hash_key(self, datas):
|
||||
"""주문 시 필요한 Hash Key 생성 (Koreainvestment header 특화)"""
|
||||
@@ -94,16 +170,62 @@ class KISClient:
|
||||
print(f"❌ Hash Key 생성 실패: {e}")
|
||||
return None
|
||||
|
||||
def get_balance(self):
|
||||
"""주식 잔고 조회"""
|
||||
def _request_api(self, method, endpoint, tr_id, params=None, data=None, use_hash=False):
|
||||
"""API 요청 공통 핸들러 (토큰 만료 시 자동 갱신)"""
|
||||
self._throttle()
|
||||
self.ensure_token()
|
||||
|
||||
# 국내주식 잔고조회 TR ID: VTTC8434R (모의), TTTC8434R (실전)
|
||||
tr_id = "VTTC8434R" if self.is_virtual else "TTTC8434R"
|
||||
url = f"{self.base_url}/uapi/domestic-stock/v1/trading/inquire-balance"
|
||||
url = f"{self.base_url}/{endpoint}"
|
||||
headers = self._get_headers(tr_id)
|
||||
|
||||
headers = self._get_headers(tr_id=tr_id)
|
||||
if use_hash and data:
|
||||
hash_key = self.get_hash_key(data)
|
||||
if hash_key:
|
||||
headers["hashkey"] = hash_key
|
||||
|
||||
try:
|
||||
if method == "GET":
|
||||
res = requests.get(url, headers=headers, params=params)
|
||||
else:
|
||||
res = requests.post(url, headers=headers, json=data)
|
||||
|
||||
# 토큰 만료 체크 (500 에러 or msg_cd 확인)
|
||||
is_token_error = False
|
||||
try:
|
||||
# KIS는 토큰 만료 시 500을 주거나 200/403 등과 함께 msg_cd로 알려줌
|
||||
if res.status_code == 500 or res.status_code == 401 or res.status_code == 403:
|
||||
err_data = res.json()
|
||||
# EGW00121: 유효하지 않은 토큰, EGW00123: 만료된 토큰
|
||||
if err_data.get('msg_cd') in ['EGW00121', 'EGW00123']:
|
||||
is_token_error = True
|
||||
except:
|
||||
pass
|
||||
|
||||
if is_token_error:
|
||||
print("🔄 [KIS] Token expired (caught). Refreshing...")
|
||||
self.ensure_token(force=True)
|
||||
headers = self._get_headers(tr_id)
|
||||
if use_hash and data and "hashkey" in headers:
|
||||
pass # Hash 재활용
|
||||
|
||||
if method == "GET":
|
||||
res = requests.get(url, headers=headers, params=params)
|
||||
else:
|
||||
res = requests.post(url, headers=headers, json=data)
|
||||
|
||||
res.raise_for_status()
|
||||
return res.json()
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ [KIS] API Request Failed: {url} | {e}")
|
||||
if isinstance(e, requests.exceptions.RequestException) and e.response is not None:
|
||||
print(f"📄 [KIS Error Body]: {e.response.text}")
|
||||
raise e
|
||||
|
||||
def get_balance(self):
|
||||
"""주식 잔고 조회"""
|
||||
tr_id = "VTTC8434R" if self.is_virtual else "TTTC8434R"
|
||||
endpoint = "uapi/domestic-stock/v1/trading/inquire-balance"
|
||||
|
||||
# 쿼리 파라미터
|
||||
params = {
|
||||
@@ -121,9 +243,7 @@ class KISClient:
|
||||
}
|
||||
|
||||
try:
|
||||
res = requests.get(url, headers=headers, params=params)
|
||||
res.raise_for_status()
|
||||
data = res.json()
|
||||
data = self._request_api("GET", endpoint, tr_id, params=params)
|
||||
|
||||
# 응답 정리
|
||||
if data['rt_cd'] != '0':
|
||||
@@ -149,10 +269,6 @@ class KISClient:
|
||||
"deposit": int(summary['dnca_tot_amt'])
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"❌ [KIS] 잔고 조회 실패: {e}")
|
||||
# If it's a requests error, verify if there's a response body
|
||||
if isinstance(e, requests.exceptions.RequestException) and e.response is not None:
|
||||
print(f"📄 [KIS Error Body]: {e.response.text}")
|
||||
return {"error": str(e)}
|
||||
|
||||
def order(self, ticker, qty, buy_sell, price=0):
|
||||
@@ -311,20 +427,14 @@ class KISClient:
|
||||
"""지수 현재가 조회 (업종/지수)
|
||||
ticker: 0001 (KOSPI), 1001 (KOSDAQ), etc.
|
||||
"""
|
||||
self._throttle()
|
||||
self.ensure_token()
|
||||
url = f"{self.base_url}/uapi/domestic-stock/v1/quotations/inquire-index-price"
|
||||
headers = self._get_headers(tr_id="FHKUP03500100")
|
||||
|
||||
endpoint = "uapi/domestic-stock/v1/quotations/inquire-index-price"
|
||||
params = {
|
||||
"FID_COND_MRKT_DIV_CODE": "U", # U: 업종/지수
|
||||
"FID_INPUT_ISCD": ticker
|
||||
}
|
||||
|
||||
try:
|
||||
res = requests.get(url, headers=headers, params=params)
|
||||
res.raise_for_status()
|
||||
data = res.json()
|
||||
data = self._request_api("GET", endpoint, "FHKUP03500100", params=params)
|
||||
if data['rt_cd'] != '0':
|
||||
return None
|
||||
return {
|
||||
@@ -340,10 +450,7 @@ class KISClient:
|
||||
|
||||
def get_daily_index_price(self, ticker, period="D"):
|
||||
"""지수 일별 시세 조회 (Market Stress Index용)"""
|
||||
self._throttle()
|
||||
self.ensure_token()
|
||||
url = f"{self.base_url}/uapi/domestic-stock/v1/quotations/inquire-daily-indexchartprice"
|
||||
headers = self._get_headers(tr_id="FHKUP03500200")
|
||||
endpoint = "uapi/domestic-stock/v1/quotations/inquire-daily-indexchartprice"
|
||||
|
||||
# 날짜 계산 (최근 100일)
|
||||
end_dt = datetime.now().strftime("%Y%m%d")
|
||||
@@ -358,11 +465,8 @@ class KISClient:
|
||||
"FID_ORG_ADJ_PRC": "0" # 수정주가 반영 여부
|
||||
}
|
||||
|
||||
|
||||
try:
|
||||
res = requests.get(url, headers=headers, params=params)
|
||||
res.raise_for_status()
|
||||
data = res.json()
|
||||
data = self._request_api("GET", endpoint, "FHKUP03500200", params=params)
|
||||
if data['rt_cd'] != '0':
|
||||
return []
|
||||
|
||||
@@ -373,3 +477,38 @@ class KISClient:
|
||||
except Exception as e:
|
||||
print(f"❌ 지수 일별 시세 조회 실패({ticker}): {e}")
|
||||
return []
|
||||
|
||||
def get_investor_trend(self, ticker):
|
||||
"""종목별 투자자(외인/기관) 매매동향 조회"""
|
||||
self._throttle()
|
||||
self.ensure_token()
|
||||
url = f"{self.base_url}/uapi/domestic-stock/v1/quotations/inquire-investor"
|
||||
headers = self._get_headers(tr_id="FHKST01010900")
|
||||
|
||||
params = {
|
||||
"FID_COND_MRKT_DIV_CODE": "J",
|
||||
"FID_INPUT_ISCD": ticker
|
||||
}
|
||||
|
||||
try:
|
||||
res = requests.get(url, headers=headers, params=params)
|
||||
res.raise_for_status()
|
||||
data = res.json()
|
||||
if data['rt_cd'] != '0':
|
||||
return None
|
||||
|
||||
# output 리스트: [ {stck_bsop_date: 날짜, frgn_ntby_qty: 외인순매수, orgn_ntby_qty: 기관순매수, ...}, ... ]
|
||||
trends = []
|
||||
for item in data['output'][:5]: # 최근 5일치만
|
||||
trends.append({
|
||||
"date": item['stck_bsop_date'],
|
||||
"foreigner": self._safe_int(item.get('frgn_ntby_qty')), # 외인 순매수량
|
||||
"institutional": self._safe_int(item.get('orgn_ntby_qty')), # 기관 순매수량
|
||||
"price_change": float(item['prdy_vrss']) # 전일대비 등락금액
|
||||
})
|
||||
|
||||
# 최근일이 0번 인덱스임
|
||||
return trends
|
||||
except Exception as e:
|
||||
print(f"❌ 투자자 동향 조회 실패({ticker}): {e}")
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user