[잔고 관리] - _today_buy_total 인스턴스 변수로 당일 누적 매수 추적 (KIS T+2 미차감 보완) - MAX_BUY_PER_CYCLE, MAX_DAILY_BUY_RATIO 설정 추가 - available_deposit = max_daily_buy - effective_today_buy 계산 [앙상블 & 포지션 사이징] - AdaptiveEnsemble 실제 연동 (하드코딩 가중치 제거) - Kelly Criterion Half-Kelly 포지션 비중 계산 - SignalWeights.normalize() Water-Filling 알고리즘으로 경계 위반 해결 - _accuracy_weighted() 크기 가중 정확도로 통일 - ensemble_weights.json → ensemble_history.json 통합 [LLM 클라이언트] - GeminiLLMClient 추가 (Gemini → Ollama 폴백 체인) - _class_last_call_ts 클래스 변수로 워커 재시작 후에도 스로틀 유지 - Ollama 미실행 조기 감지 및 명확한 오류 메시지 [KIS API] - 모든 requests.get/post에 timeout=Config.HTTP_TIMEOUT 적용 - get_balance()에 today_buy_amt 필드 추가 [장중 전용 운영] - KRXCalendar: exchange_calendars 기반, 2024~2026 공휴일 하드코딩 폴백 - EOD 셧다운: 15:35에 전체 상태 저장 후 서버 자동 종료 - Watchdog: .eod_date 마커로 EOD 후 재시작 차단 - daily_launcher.py: 매일 08:30 실행, 휴장일 감지 후 봇 미시작 - Windows 작업 스케줄러 WebAI_DailyLauncher 등록 [텔레그램 스킬 수정] - PYTHONIOENCODING=utf-8 서브프로세스 환경 설정 (cp949 이모지 오류 해결) - /regime: IPC macro_indices 파싱 구현, --json 모드 input() 블로킹 제거 - /weights: ensemble_history.json 형식 파싱 업데이트 - /model_health: glob 패턴 *_v3.pt 수정 - /postmortem: 거래 없을 때 빈 JSON 출력으로 Telegram 오류 해결 - /macro: price=0 시 prev_close 폴백 표시 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
951 lines
37 KiB
Python
951 lines
37 KiB
Python
import requests
|
|
import json
|
|
import time
|
|
import os
|
|
from datetime import datetime, timedelta
|
|
|
|
try:
|
|
import aiohttp
|
|
except ImportError:
|
|
aiohttp = None
|
|
|
|
from modules.config import Config
|
|
|
|
class KISClient:
|
|
"""
|
|
한국투자증권 (Korea Investment & Securities) REST API Client
|
|
"""
|
|
def __init__(self, is_virtual=None):
|
|
# Config에서 설정 로드
|
|
self.app_key = Config.KIS_APP_KEY
|
|
self.app_secret = Config.KIS_APP_SECRET
|
|
self.cano = Config.KIS_ACCOUNT[:8]
|
|
self.acnt_prdt_cd = Config.KIS_ACCOUNT[-2:] # "01" 등
|
|
|
|
# 가상/실전 모드 설정
|
|
if is_virtual is None:
|
|
self.is_virtual = Config.KIS_IS_VIRTUAL
|
|
else:
|
|
self.is_virtual = is_virtual
|
|
|
|
self.base_url = Config.KIS_BASE_URL
|
|
|
|
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회 이하로 제한)"""
|
|
# 모의투자는 Rate Limit이 매우 엄격함 (초당 2~3회 권장)
|
|
min_interval = 0.5 # 0.5초 대기 (초당 2회)
|
|
now = time.time()
|
|
elapsed = now - self.last_req_time
|
|
|
|
if elapsed < min_interval:
|
|
time.sleep(min_interval - elapsed)
|
|
|
|
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 = {
|
|
"Content-Type": "application/json; charset=utf-8",
|
|
"authorization": f"Bearer {self.access_token}",
|
|
"appkey": self.app_key,
|
|
"appsecret": self.app_secret,
|
|
}
|
|
if tr_id:
|
|
headers["tr_id"] = tr_id
|
|
|
|
return headers
|
|
|
|
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"
|
|
payload = {
|
|
"grant_type": "client_credentials",
|
|
"appkey": self.app_key,
|
|
"appsecret": self.app_secret
|
|
}
|
|
|
|
try:
|
|
print(f"🔑 [KIS] 토큰 발급 요청: {url}")
|
|
res = requests.post(url, json=payload, timeout=Config.HTTP_TIMEOUT)
|
|
res.raise_for_status()
|
|
data = res.json()
|
|
|
|
self.access_token = data.get('access_token')
|
|
|
|
# 만료 시간 설정
|
|
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:
|
|
# 1분 제한 에러 핸들링 (EGW00133)
|
|
retry = False
|
|
if isinstance(e, requests.exceptions.RequestException) and e.response is not None:
|
|
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 특화)"""
|
|
url = f"{self.base_url}/uapi/hashkey"
|
|
headers = {
|
|
"content-type": "application/json; charset=utf-8",
|
|
"appkey": self.app_key,
|
|
"appsecret": self.app_secret
|
|
}
|
|
try:
|
|
res = requests.post(url, headers=headers, json=datas, timeout=Config.HTTP_TIMEOUT)
|
|
return res.json()["HASH"]
|
|
except Exception as e:
|
|
print(f"❌ Hash Key 생성 실패: {e}")
|
|
return None
|
|
|
|
def _request_api(self, method, endpoint, tr_id, params=None, data=None, use_hash=False):
|
|
"""API 요청 공통 핸들러 (토큰 만료 시 자동 갱신)"""
|
|
self._throttle()
|
|
self.ensure_token()
|
|
|
|
url = f"{self.base_url}/{endpoint}"
|
|
headers = self._get_headers(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,
|
|
timeout=Config.HTTP_TIMEOUT)
|
|
else:
|
|
res = requests.post(url, headers=headers, json=data,
|
|
timeout=Config.HTTP_TIMEOUT)
|
|
|
|
# 토큰 만료 체크 (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,
|
|
timeout=Config.HTTP_TIMEOUT)
|
|
else:
|
|
res = requests.post(url, headers=headers, json=data,
|
|
timeout=Config.HTTP_TIMEOUT)
|
|
|
|
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 = {
|
|
"CANO": self.cano,
|
|
"ACNT_PRDT_CD": self.acnt_prdt_cd,
|
|
"AFHR_FLPR_YN": "N",
|
|
"OFL_YN": "",
|
|
"INQR_DVSN": "02",
|
|
"UNPR_DVSN": "01",
|
|
"FUND_STTL_ICLD_YN": "N",
|
|
"FNCG_AMT_AUTO_RDPT_YN": "N",
|
|
"PRCS_DVSN": "00",
|
|
"CTX_AREA_FK100": "",
|
|
"CTX_AREA_NK100": ""
|
|
}
|
|
|
|
try:
|
|
data = self._request_api("GET", endpoint, tr_id, params=params)
|
|
|
|
# 응답 정리
|
|
if data['rt_cd'] != '0':
|
|
return {"error": data['msg1']}
|
|
|
|
holdings = []
|
|
for item in data['output1']:
|
|
if int(item['hldg_qty']) > 0:
|
|
holdings.append({
|
|
"code": item['pdno'],
|
|
"name": item['prdt_name'],
|
|
"qty": int(item['hldg_qty']),
|
|
"yield": float(item['evlu_pfls_rt']),
|
|
"purchase_price": float(item['pchs_avg_pric']), # 매입평균가
|
|
"current_price": float(item['prpr']), # 현재가
|
|
"profit_loss": int(item['evlu_pfls_amt']) # 평가손익
|
|
})
|
|
|
|
summary = data['output2'][0]
|
|
return {
|
|
"holdings": holdings,
|
|
"total_eval": int(summary['tot_evlu_amt']),
|
|
"deposit": int(summary['dnca_tot_amt']),
|
|
"today_buy_amt": int(summary.get('thdt_buy_amt', 0)), # 금일매수금액 (T+2 차감 전 당일 집행액)
|
|
}
|
|
except Exception as e:
|
|
return {"error": str(e)}
|
|
|
|
def order(self, ticker, qty, buy_sell, price=0, order_type="market"):
|
|
"""주문
|
|
buy_sell: 'BUY' or 'SELL'
|
|
order_type: 'market'(시장가), 'limit'(지정가), 'conditional'(조건부지정가)
|
|
price: 지정가일 때 주문 가격 (market이면 무시)
|
|
"""
|
|
self._throttle()
|
|
self.ensure_token()
|
|
|
|
# 모의투자/실전 TR ID 구분
|
|
if buy_sell == 'BUY':
|
|
tr_id = "VTTC0802U" if self.is_virtual else "TTTC0802U"
|
|
else:
|
|
tr_id = "VTTC0801U" if self.is_virtual else "TTTC0801U"
|
|
|
|
# 주문 구분 코드
|
|
# 00: 지정가, 01: 시장가, 03: 최유리지정가, 05: 장전시간외, 06: 장후시간외
|
|
if order_type == "limit" and price > 0:
|
|
ord_dvsn = "00"
|
|
ord_unpr = str(int(price))
|
|
order_type_str = f"지정가({price:,.0f})"
|
|
elif order_type == "conditional" and price > 0:
|
|
ord_dvsn = "03" # 최유리지정가
|
|
ord_unpr = str(int(price))
|
|
order_type_str = f"조건부({price:,.0f})"
|
|
else:
|
|
ord_dvsn = "01" # 시장가
|
|
ord_unpr = "0"
|
|
order_type_str = "시장가"
|
|
|
|
url = f"{self.base_url}/uapi/domestic-stock/v1/trading/order-cash"
|
|
|
|
datas = {
|
|
"CANO": self.cano,
|
|
"ACNT_PRDT_CD": self.acnt_prdt_cd,
|
|
"PDNO": ticker,
|
|
"ORD_DVSN": ord_dvsn,
|
|
"ORD_QTY": str(qty),
|
|
"ORD_UNPR": ord_unpr
|
|
}
|
|
|
|
headers = self._get_headers(tr_id=tr_id)
|
|
hash_key = self.get_hash_key(datas)
|
|
if hash_key:
|
|
headers["hashkey"] = hash_key
|
|
else:
|
|
print("⚠️ [KIS] Hash Key 생성 실패 (주문 전송 시도)")
|
|
|
|
try:
|
|
print(f"📤 [KIS] 주문 전송: {buy_sell} {ticker} {qty}ea ({order_type_str})")
|
|
res = requests.post(url, headers=headers, json=datas, timeout=Config.HTTP_TIMEOUT)
|
|
res.raise_for_status()
|
|
data = res.json()
|
|
|
|
print(f"📥 [KIS] 주문 응답 코드(rt_cd): {data['rt_cd']}")
|
|
print(f"📥 [KIS] 주문 응답 메시지(msg1): {data['msg1']}")
|
|
|
|
if data['rt_cd'] != '0':
|
|
return {"status": False, "msg": data['msg1'], "rt_cd": data['rt_cd']}
|
|
|
|
return {"status": True, "msg": "주문 전송 완료", "order_no": data['output']['ODNO'], "rt_cd": data['rt_cd']}
|
|
except Exception as e:
|
|
return {"status": False, "msg": str(e), "rt_cd": "EXCEPTION"}
|
|
|
|
def get_current_price(self, ticker):
|
|
"""현재가 조회"""
|
|
self._throttle()
|
|
self.ensure_token()
|
|
url = f"{self.base_url}/uapi/domestic-stock/v1/quotations/inquire-price"
|
|
headers = self._get_headers(tr_id="FHKST01010100")
|
|
|
|
params = {
|
|
"FID_COND_MRKT_DIV_CODE": "J",
|
|
"FID_INPUT_ISCD": ticker
|
|
}
|
|
|
|
try:
|
|
res = requests.get(url, headers=headers, params=params,
|
|
timeout=Config.HTTP_TIMEOUT)
|
|
res.raise_for_status()
|
|
data = res.json()
|
|
if data['rt_cd'] != '0':
|
|
return None
|
|
return int(data['output']['stck_prpr']) # 현재가
|
|
except Exception as e:
|
|
print(f"❌ 현재가 조회 실패: {e}")
|
|
return None
|
|
|
|
def _get_daily_ohlcv_by_range(self, ticker, period="D", count=100):
|
|
"""기간별시세 API (FHKST03010100) - OHLCV 전체 반환
|
|
output2에서 stck_oprc, stck_hgpr, stck_lwpr, stck_clpr, acml_vol 파싱
|
|
"""
|
|
self._throttle()
|
|
self.ensure_token()
|
|
|
|
end_date = datetime.now().strftime("%Y%m%d")
|
|
start_date = (datetime.now() - timedelta(days=int(count * 1.6))).strftime("%Y%m%d")
|
|
|
|
url = f"{self.base_url}/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice"
|
|
headers = self._get_headers(tr_id="FHKST03010100")
|
|
|
|
params = {
|
|
"FID_COND_MRKT_DIV_CODE": "J",
|
|
"FID_INPUT_ISCD": ticker,
|
|
"FID_INPUT_DATE_1": start_date,
|
|
"FID_INPUT_DATE_2": end_date,
|
|
"FID_PERIOD_DIV_CODE": period,
|
|
"FID_ORG_ADJ_PRC": "1"
|
|
}
|
|
|
|
try:
|
|
res = requests.get(url, headers=headers, params=params,
|
|
timeout=Config.HTTP_TIMEOUT)
|
|
res.raise_for_status()
|
|
data = res.json()
|
|
|
|
if data.get('rt_cd') != '0':
|
|
return None
|
|
|
|
output = data.get('output2', [])
|
|
if not output:
|
|
return None
|
|
|
|
opens, highs, lows, closes, volumes = [], [], [], [], []
|
|
for item in output:
|
|
try:
|
|
c = int(item.get('stck_clpr', 0) or 0)
|
|
o = int(item.get('stck_oprc', 0) or 0)
|
|
h = int(item.get('stck_hgpr', 0) or 0)
|
|
l = int(item.get('stck_lwpr', 0) or 0)
|
|
v = int(item.get('acml_vol', 0) or 0)
|
|
if c > 0:
|
|
opens.append(o if o > 0 else c)
|
|
highs.append(h if h > 0 else c)
|
|
lows.append(l if l > 0 else c)
|
|
closes.append(c)
|
|
volumes.append(v)
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
if not closes:
|
|
return None
|
|
|
|
# API는 최신순 → 과거→현재 순으로 변환
|
|
opens.reverse(); highs.reverse(); lows.reverse()
|
|
closes.reverse(); volumes.reverse()
|
|
|
|
result = {
|
|
'open': opens[-count:],
|
|
'high': highs[-count:],
|
|
'low': lows[-count:],
|
|
'close': closes[-count:],
|
|
'volume': volumes[-count:]
|
|
}
|
|
print(f"[KIS] {ticker} OHLCV: {len(result['close'])}개 ({start_date}~{end_date})")
|
|
return result
|
|
|
|
except Exception as e:
|
|
print(f"⚠️ [KIS] OHLCV 조회 실패 ({ticker}): {e}")
|
|
return None
|
|
|
|
def get_daily_ohlcv(self, ticker, period="D", count=100):
|
|
"""일별 OHLCV 시세 조회 (기술적 분석 + LSTM 7차원 입력용)
|
|
1차: 기간별시세 API OHLCV 파싱 (100일)
|
|
2차: 기존 close-only fallback
|
|
"""
|
|
ohlcv = self._get_daily_ohlcv_by_range(ticker, period, count)
|
|
if ohlcv and len(ohlcv['close']) >= 30:
|
|
return ohlcv
|
|
|
|
# fallback: close만 반환 (가짜 OHLCV)
|
|
print(f"[KIS] {ticker} OHLCV 실패 → close-only fallback")
|
|
prices = self._get_daily_price_by_range(ticker, period, count)
|
|
if not prices:
|
|
return None
|
|
return {
|
|
'open': prices, 'high': prices, 'low': prices,
|
|
'close': prices, 'volume': []
|
|
}
|
|
|
|
def _get_daily_price_by_range(self, ticker, period="D", count=100):
|
|
"""기간별시세 API (FHKST03010100) - 날짜 범위로 최대 100일 데이터 반환
|
|
inquire-daily-price(FHKST01010400)가 30일만 반환하는 한계 극복"""
|
|
self._throttle()
|
|
self.ensure_token()
|
|
|
|
end_date = datetime.now().strftime("%Y%m%d")
|
|
# 영업일 count개 확보를 위해 역일 1.6배 요청 (주말/공휴일 여유)
|
|
start_date = (datetime.now() - timedelta(days=int(count * 1.6))).strftime("%Y%m%d")
|
|
|
|
url = f"{self.base_url}/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice"
|
|
headers = self._get_headers(tr_id="FHKST03010100")
|
|
|
|
params = {
|
|
"FID_COND_MRKT_DIV_CODE": "J",
|
|
"FID_INPUT_ISCD": ticker,
|
|
"FID_INPUT_DATE_1": start_date,
|
|
"FID_INPUT_DATE_2": end_date,
|
|
"FID_PERIOD_DIV_CODE": period,
|
|
"FID_ORG_ADJ_PRC": "1"
|
|
}
|
|
|
|
try:
|
|
res = requests.get(url, headers=headers, params=params,
|
|
timeout=Config.HTTP_TIMEOUT)
|
|
res.raise_for_status()
|
|
data = res.json()
|
|
|
|
if data.get('rt_cd') != '0':
|
|
return []
|
|
|
|
# 기간별시세는 output2에 배열로 반환
|
|
output = data.get('output2', [])
|
|
if not output:
|
|
return []
|
|
|
|
prices = []
|
|
for item in output:
|
|
clpr = item.get('stck_clpr', '')
|
|
if clpr and clpr != '0':
|
|
try:
|
|
prices.append(int(clpr))
|
|
except ValueError:
|
|
pass
|
|
|
|
prices.reverse() # API는 최신순 → 과거→현재 순으로 변환
|
|
result = prices[-count:]
|
|
print(f"[KIS] {ticker} 기간별시세: {len(result)}개 "
|
|
f"({start_date}~{end_date})")
|
|
return result
|
|
|
|
except Exception as e:
|
|
print(f"⚠️ [KIS] 기간별시세 조회 실패 ({ticker}): {e}")
|
|
return []
|
|
|
|
def get_daily_price(self, ticker, period="D", count=100):
|
|
"""일별 시세 조회 (기술적 분석 + LSTM용)
|
|
1차: 기간별시세 API (100일, LSTM 학습 가능)
|
|
2차: 구형 API fallback (30일)
|
|
"""
|
|
# 1차: 기간별시세 API (FHKST03010100) - 100일
|
|
prices = self._get_daily_price_by_range(ticker, period, count)
|
|
if prices and len(prices) >= 30:
|
|
return prices
|
|
|
|
# 2차: 구형 API fallback (FHKST01010400) - 30일
|
|
print(f"[KIS] {ticker} 기간별시세 실패 → 구형 API(30일) fallback")
|
|
self._throttle()
|
|
self.ensure_token()
|
|
url = f"{self.base_url}/uapi/domestic-stock/v1/quotations/inquire-daily-price"
|
|
headers = self._get_headers(tr_id="FHKST01010400")
|
|
params = {
|
|
"FID_COND_MRKT_DIV_CODE": "J",
|
|
"FID_INPUT_ISCD": ticker,
|
|
"FID_PERIOD_DIV_CODE": period,
|
|
"FID_ORG_ADJ_PRC": "1"
|
|
}
|
|
try:
|
|
res = requests.get(url, headers=headers, params=params,
|
|
timeout=Config.HTTP_TIMEOUT)
|
|
res.raise_for_status()
|
|
data = res.json()
|
|
if data.get('rt_cd') != '0':
|
|
return []
|
|
prices = [int(item['stck_clpr']) for item in data['output']
|
|
if item.get('stck_clpr')]
|
|
prices.reverse()
|
|
return prices
|
|
except Exception as e:
|
|
print(f"❌ 일별 시세 조회 실패 ({ticker}): {e}")
|
|
return []
|
|
|
|
def get_volume_rank(self, limit=5):
|
|
"""거래량 상위 종목 조회"""
|
|
self._throttle()
|
|
self.ensure_token()
|
|
url = f"{self.base_url}/uapi/domestic-stock/v1/quotations/volume-rank"
|
|
headers = self._get_headers(tr_id="FHPST01710000")
|
|
|
|
params = {
|
|
"FID_COND_MRKT_DIV_CODE": "J", # 주식, ETF, ETN 전체
|
|
"FID_COND_SCR_RSLT_GD_CD": "20171", # 전체
|
|
"FID_INPUT_ISCD": "0000", # 전체
|
|
"FID_DIV_CLS_CODE": "0", # 0: 전체
|
|
"FID_BLNG_CLS_CODE": "0", # 0: 전체
|
|
"FID_TRGT_CLS_CODE": "111111111", # 필터링 조건 (이대로 두면 됨)
|
|
"FID_TRGT_EXCLS_CLS_CODE": "0000000000", # 제외 조건
|
|
"FID_INPUT_PRICE_1": "",
|
|
"FID_INPUT_PRICE_2": "",
|
|
"FID_VOL_CNT": "",
|
|
"FID_INPUT_DATE_1": ""
|
|
}
|
|
|
|
try:
|
|
res = requests.get(url, headers=headers, params=params,
|
|
timeout=Config.HTTP_TIMEOUT)
|
|
res.raise_for_status()
|
|
data = res.json()
|
|
if data['rt_cd'] != '0':
|
|
return []
|
|
|
|
results = []
|
|
for item in data['output'][:limit]:
|
|
# 코드는 shtn_iscd, 이름은 hts_kor_isnm
|
|
results.append({
|
|
"code": item['mksc_shrn_iscd'],
|
|
"name": item['hts_kor_isnm'],
|
|
"volume": int(item['acml_vol']),
|
|
"price": int(item['stck_prpr'])
|
|
})
|
|
return results
|
|
except Exception as e:
|
|
print(f"❌ 거래량 순위 조회 실패: {e}")
|
|
return []
|
|
|
|
def buy_stock(self, ticker, qty):
|
|
return self.order(ticker, qty, 'BUY')
|
|
|
|
def get_current_index(self, ticker):
|
|
"""지수 현재가 조회 (업종/지수)
|
|
ticker: 0001 (KOSPI), 1001 (KOSDAQ), etc.
|
|
"""
|
|
endpoint = "uapi/domestic-stock/v1/quotations/inquire-index-price"
|
|
params = {
|
|
"FID_COND_MRKT_DIV_CODE": "U", # U: 업종/지수
|
|
"FID_INPUT_ISCD": ticker
|
|
}
|
|
|
|
try:
|
|
data = self._request_api("GET", endpoint, "FHKUP03500100", params=params)
|
|
if data['rt_cd'] != '0':
|
|
return None
|
|
o = data['output']
|
|
def _f(val): return float(val) if val else 0.0
|
|
def _i(val): return int(float(val)) if val else 0
|
|
return {
|
|
"price": _f(o.get('bstp_nmix_prpr')), # 현재지수
|
|
"change": _f(o.get('bstp_nmix_prdy_ctrt')), # 등락률(%)
|
|
"change_val": _f(o.get('bstp_nmix_prdy_vrss')), # 전일 대비 포인트
|
|
"high": _f(o.get('bstp_nmix_hgpr')), # 장중 고가
|
|
"low": _f(o.get('bstp_nmix_lwpr')), # 장중 저가
|
|
"prev_close": _f(o.get('prdy_nmix')), # 전일 종가
|
|
"volume": _i(o.get('acml_vol')), # 누적 거래량(천주)
|
|
"trade_value": _i(o.get('acml_tr_pbmn')), # 누적 거래대금(백만원)
|
|
}
|
|
except Exception as e:
|
|
print(f"❌ 지수 조회 실패({ticker}): {e}")
|
|
return None
|
|
|
|
def sell_stock(self, ticker, qty):
|
|
return self.order(ticker, qty, 'SELL')
|
|
|
|
def get_daily_index_price(self, ticker, period="D"):
|
|
"""지수 일별 시세 조회 (Market Stress Index용)"""
|
|
endpoint = "uapi/domestic-stock/v1/quotations/inquire-daily-indexchartprice"
|
|
|
|
# 날짜 계산 (최근 100일)
|
|
end_dt = datetime.now().strftime("%Y%m%d")
|
|
start_dt = (datetime.now() - timedelta(days=100)).strftime("%Y%m%d")
|
|
|
|
params = {
|
|
"FID_COND_MRKT_DIV_CODE": "U", # U: 업종/지수
|
|
"FID_INPUT_ISCD": ticker,
|
|
"FID_INPUT_DATE_1": start_dt, # 시작일
|
|
"FID_INPUT_DATE_2": end_dt, # 종료일
|
|
"FID_PERIOD_DIV_CODE": period,
|
|
"FID_ORG_ADJ_PRC": "0" # 수정주가 반영 여부
|
|
}
|
|
|
|
try:
|
|
data = self._request_api("GET", endpoint, "FHKUP03500200", params=params)
|
|
if data['rt_cd'] != '0':
|
|
return []
|
|
|
|
# output 리스트: [ {bstp_nmix_prpr: 지수, ...}, ... ]
|
|
prices = [float(item['bstp_nmix_prpr']) for item in data['output']]
|
|
prices.reverse() # 과거 -> 현재
|
|
return prices
|
|
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,
|
|
timeout=Config.HTTP_TIMEOUT)
|
|
res.raise_for_status()
|
|
data = res.json()
|
|
if data['rt_cd'] != '0':
|
|
return None
|
|
|
|
trends = []
|
|
for item in data['output'][: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'])
|
|
})
|
|
|
|
return trends
|
|
except Exception as e:
|
|
print(f"[KIS] 투자자 동향 조회 실패({ticker}): {e}")
|
|
return None
|
|
|
|
|
|
class KISAsyncClient:
|
|
"""
|
|
비동기 KIS API 클라이언트
|
|
- aiohttp 기반 HTTP 호출
|
|
- 동기 KISClient의 토큰/설정을 공유
|
|
- 다중 종목 병렬 수집용
|
|
"""
|
|
def __init__(self, sync_client):
|
|
self.sync = sync_client
|
|
self.min_interval = 0.5 # 초당 2회 제한
|
|
|
|
async def _async_get(self, session, url, headers, params):
|
|
"""비동기 GET 요청"""
|
|
try:
|
|
timeout = aiohttp.ClientTimeout(total=Config.HTTP_TIMEOUT) if aiohttp else None
|
|
async with session.get(url, headers=headers, params=params,
|
|
timeout=timeout) as resp:
|
|
return await resp.json()
|
|
except Exception as e:
|
|
print(f"[KIS Async] Request failed: {e}")
|
|
return None
|
|
|
|
async def get_daily_price_async(self, ticker):
|
|
"""비동기 일별 시세 조회 (close only, 하위 호환)"""
|
|
import aiohttp
|
|
|
|
self.sync.ensure_token()
|
|
url = f"{self.sync.base_url}/uapi/domestic-stock/v1/quotations/inquire-daily-price"
|
|
headers = self.sync._get_headers(tr_id="FHKST01010400")
|
|
params = {
|
|
"FID_COND_MRKT_DIV_CODE": "J",
|
|
"FID_INPUT_ISCD": ticker,
|
|
"FID_PERIOD_DIV_CODE": "D",
|
|
"FID_ORG_ADJ_PRC": "1"
|
|
}
|
|
|
|
async with aiohttp.ClientSession() as session:
|
|
data = await self._async_get(session, url, headers, params)
|
|
if data and data.get('rt_cd') == '0':
|
|
prices = [int(item['stck_clpr']) for item in data['output']]
|
|
prices.reverse()
|
|
return prices
|
|
return []
|
|
|
|
async def get_daily_ohlcv_async(self, ticker, count=100):
|
|
"""비동기 OHLCV 조회 (기간별시세 API 사용)"""
|
|
import aiohttp
|
|
from datetime import datetime, timedelta
|
|
|
|
self.sync.ensure_token()
|
|
end_date = datetime.now().strftime("%Y%m%d")
|
|
start_date = (datetime.now() - timedelta(days=int(count * 1.6))).strftime("%Y%m%d")
|
|
|
|
url = f"{self.sync.base_url}/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice"
|
|
headers = self.sync._get_headers(tr_id="FHKST03010100")
|
|
params = {
|
|
"FID_COND_MRKT_DIV_CODE": "J",
|
|
"FID_INPUT_ISCD": ticker,
|
|
"FID_INPUT_DATE_1": start_date,
|
|
"FID_INPUT_DATE_2": end_date,
|
|
"FID_PERIOD_DIV_CODE": "D",
|
|
"FID_ORG_ADJ_PRC": "1"
|
|
}
|
|
|
|
async with aiohttp.ClientSession() as session:
|
|
data = await self._async_get(session, url, headers, params)
|
|
if data and data.get('rt_cd') == '0':
|
|
output = data.get('output2', [])
|
|
opens, highs, lows, closes, volumes = [], [], [], [], []
|
|
for item in output:
|
|
try:
|
|
c = int(item.get('stck_clpr', 0) or 0)
|
|
if c > 0:
|
|
opens.append(int(item.get('stck_oprc', 0) or c))
|
|
highs.append(int(item.get('stck_hgpr', 0) or c))
|
|
lows.append(int(item.get('stck_lwpr', 0) or c))
|
|
closes.append(c)
|
|
volumes.append(int(item.get('acml_vol', 0) or 0))
|
|
except (ValueError, TypeError):
|
|
pass
|
|
if closes:
|
|
opens.reverse(); highs.reverse(); lows.reverse()
|
|
closes.reverse(); volumes.reverse()
|
|
return {
|
|
'open': opens[-count:], 'high': highs[-count:],
|
|
'low': lows[-count:], 'close': closes[-count:],
|
|
'volume': volumes[-count:]
|
|
}
|
|
return None
|
|
|
|
async def get_investor_trend_async(self, ticker):
|
|
"""비동기 투자자 동향 조회"""
|
|
import aiohttp
|
|
|
|
self.sync.ensure_token()
|
|
url = f"{self.sync.base_url}/uapi/domestic-stock/v1/quotations/inquire-investor"
|
|
headers = self.sync._get_headers(tr_id="FHKST01010900")
|
|
params = {
|
|
"FID_COND_MRKT_DIV_CODE": "J",
|
|
"FID_INPUT_ISCD": ticker
|
|
}
|
|
|
|
async with aiohttp.ClientSession() as session:
|
|
data = await self._async_get(session, url, headers, params)
|
|
if data and data.get('rt_cd') == '0':
|
|
trends = []
|
|
for item in data['output'][:5]:
|
|
trends.append({
|
|
"date": item['stck_bsop_date'],
|
|
"foreigner": self.sync._safe_int(item.get('frgn_ntby_qty')),
|
|
"institutional": self.sync._safe_int(item.get('orgn_ntby_qty')),
|
|
"price_change": float(item['prdy_vrss'])
|
|
})
|
|
return trends
|
|
return None
|
|
|
|
async def get_daily_prices_batch(self, tickers):
|
|
"""여러 종목의 일별 시세(close only)를 병렬로 조회 (하위 호환)"""
|
|
import aiohttp
|
|
import asyncio
|
|
|
|
self.sync.ensure_token()
|
|
results = {}
|
|
|
|
async with aiohttp.ClientSession() as session:
|
|
tasks = []
|
|
for i, ticker in enumerate(tickers):
|
|
if i > 0:
|
|
await asyncio.sleep(self.min_interval)
|
|
|
|
url = f"{self.sync.base_url}/uapi/domestic-stock/v1/quotations/inquire-daily-price"
|
|
headers = self.sync._get_headers(tr_id="FHKST01010400")
|
|
params = {
|
|
"FID_COND_MRKT_DIV_CODE": "J",
|
|
"FID_INPUT_ISCD": ticker,
|
|
"FID_PERIOD_DIV_CODE": "D",
|
|
"FID_ORG_ADJ_PRC": "1"
|
|
}
|
|
tasks.append((ticker, self._async_get(session, url, headers, params)))
|
|
|
|
for ticker, task in tasks:
|
|
data = await task
|
|
if data and data.get('rt_cd') == '0':
|
|
prices = [int(item['stck_clpr']) for item in data['output']]
|
|
prices.reverse()
|
|
results[ticker] = prices
|
|
else:
|
|
results[ticker] = []
|
|
|
|
return results
|
|
|
|
async def get_daily_ohlcv_batch(self, tickers, count=100):
|
|
"""여러 종목의 OHLCV를 병렬로 조회 (기간별시세 API)"""
|
|
import aiohttp
|
|
import asyncio
|
|
from datetime import datetime, timedelta
|
|
|
|
self.sync.ensure_token()
|
|
results = {}
|
|
|
|
end_date = datetime.now().strftime("%Y%m%d")
|
|
start_date = (datetime.now() - timedelta(days=int(count * 1.6))).strftime("%Y%m%d")
|
|
|
|
async with aiohttp.ClientSession() as session:
|
|
tasks = []
|
|
for i, ticker in enumerate(tickers):
|
|
if i > 0:
|
|
await asyncio.sleep(self.min_interval)
|
|
|
|
url = f"{self.sync.base_url}/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice"
|
|
headers = self.sync._get_headers(tr_id="FHKST03010100")
|
|
params = {
|
|
"FID_COND_MRKT_DIV_CODE": "J",
|
|
"FID_INPUT_ISCD": ticker,
|
|
"FID_INPUT_DATE_1": start_date,
|
|
"FID_INPUT_DATE_2": end_date,
|
|
"FID_PERIOD_DIV_CODE": "D",
|
|
"FID_ORG_ADJ_PRC": "1"
|
|
}
|
|
tasks.append((ticker, self._async_get(session, url, headers, params)))
|
|
|
|
for ticker, task in tasks:
|
|
data = await task
|
|
if data and data.get('rt_cd') == '0':
|
|
output = data.get('output2', [])
|
|
opens, highs, lows, closes, volumes = [], [], [], [], []
|
|
for item in output:
|
|
try:
|
|
c = int(item.get('stck_clpr', 0) or 0)
|
|
if c > 0:
|
|
opens.append(int(item.get('stck_oprc', 0) or c))
|
|
highs.append(int(item.get('stck_hgpr', 0) or c))
|
|
lows.append(int(item.get('stck_lwpr', 0) or c))
|
|
closes.append(c)
|
|
volumes.append(int(item.get('acml_vol', 0) or 0))
|
|
except (ValueError, TypeError):
|
|
pass
|
|
if closes:
|
|
opens.reverse(); highs.reverse(); lows.reverse()
|
|
closes.reverse(); volumes.reverse()
|
|
results[ticker] = {
|
|
'open': opens[-count:], 'high': highs[-count:],
|
|
'low': lows[-count:], 'close': closes[-count:],
|
|
'volume': volumes[-count:]
|
|
}
|
|
else:
|
|
results[ticker] = None
|
|
else:
|
|
results[ticker] = None
|
|
|
|
return results
|
|
|
|
async def get_investor_trends_batch(self, tickers):
|
|
"""여러 종목의 투자자 동향을 병렬로 조회"""
|
|
import aiohttp
|
|
import asyncio
|
|
|
|
self.sync.ensure_token()
|
|
results = {}
|
|
|
|
async with aiohttp.ClientSession() as session:
|
|
tasks = []
|
|
for i, ticker in enumerate(tickers):
|
|
if i > 0:
|
|
await asyncio.sleep(self.min_interval)
|
|
|
|
url = f"{self.sync.base_url}/uapi/domestic-stock/v1/quotations/inquire-investor"
|
|
headers = self.sync._get_headers(tr_id="FHKST01010900")
|
|
params = {
|
|
"FID_COND_MRKT_DIV_CODE": "J",
|
|
"FID_INPUT_ISCD": ticker
|
|
}
|
|
tasks.append((ticker, self._async_get(session, url, headers, params)))
|
|
|
|
for ticker, task in tasks:
|
|
data = await task
|
|
if data and data.get('rt_cd') == '0':
|
|
trends = []
|
|
for item in data['output'][:5]:
|
|
trends.append({
|
|
"date": item['stck_bsop_date'],
|
|
"foreigner": self.sync._safe_int(item.get('frgn_ntby_qty')),
|
|
"institutional": self.sync._safe_int(item.get('orgn_ntby_qty')),
|
|
"price_change": float(item['prdy_vrss'])
|
|
})
|
|
results[ticker] = trends
|
|
else:
|
|
results[ticker] = None
|
|
|
|
return results
|