Files
Stock-trading-programming/app/execution/kis_client.py
T

688 lines
27 KiB
Python
Raw Normal View History

2026-05-14 15:14:50 +09:00
"""
kis_client.py
KIS Open API REST + WebSocket 래퍼
- 토큰 자동 발급/갱신
- 모의투자/실거래 모드 자동 전환
- rate limit 제어 (모드별 요청 간격)
2026-05-14 15:14:50 +09:00
"""
import os
import json
import time
import asyncio
import aiohttp
import logging
from datetime import datetime, timedelta
from typing import Optional, Dict, Any, Callable
logger = logging.getLogger(__name__)
# ── 모드별 베이스 URL ──
URL_REAL = "https://openapi.koreainvestment.com:9443"
URL_MOCK = "https://openapivts.koreainvestment.com:29443"
class KISClient:
"""
KIS Open API 클라이언트
모의투자/실거래 모드를 .env의 KIS_MOCK 값으로 자동 전환
"""
def __init__(self):
self.is_mock = os.getenv("KIS_MOCK", "true").lower() == "true"
self.base_url = URL_MOCK if self.is_mock else URL_REAL
# 모드별 키 자동 선택
if self.is_mock:
self.app_key = os.getenv("KIS_MOCK_APP_KEY", "")
self.app_secret = os.getenv("KIS_MOCK_APP_SECRET", "")
self.account_no = os.getenv("KIS_MOCK_ACCOUNT_NO", "")
else:
self.app_key = os.getenv("KIS_APP_KEY", "")
self.app_secret = os.getenv("KIS_APP_SECRET", "")
self.account_no = os.getenv("KIS_ACCOUNT_NO", "")
# 계좌번호 파싱 (앞 8자리 + 뒤 2자리)
self._parse_account()
# 토큰 관련
self._access_token : Optional[str] = None
self._token_expires_at: Optional[datetime] = None
# 토큰 파일 캐시 경로 (재시작 시 재사용)
mode_tag = "mock" if self.is_mock else "real"
self._token_cache_file = os.path.join(
os.path.dirname(__file__), "..", "..", "data", f"kis_token_{mode_tag}.json"
)
self._load_token_from_file()
2026-05-14 15:14:50 +09:00
2026-06-15 18:52:42 +09:00
# rate limit: KIS occasionally rejects even nominally safe bursts.
# Keep defaults conservative and allow local override from .env.
self._rate_limit = int(os.getenv(
"KIS_MOCK_RATE_LIMIT" if self.is_mock else "KIS_REAL_RATE_LIMIT",
"1" if self.is_mock else "3",
))
self._request_spacing = float(os.getenv(
"KIS_MOCK_REQUEST_SPACING" if self.is_mock else "KIS_REAL_REQUEST_SPACING",
"1.7" if self.is_mock else "0.35",
))
self._cooldown_until = 0.0
self._semaphore = asyncio.Semaphore(1)
self._req_times : list = []
2026-05-14 15:14:50 +09:00
mode = "모의투자" if self.is_mock else "실거래"
logger.info(f"KISClient 초기화 완료 [{mode}] 계좌: {self.account_no}")
def _parse_account(self):
"""계좌번호 파싱: '50123456-01' → ('50123456', '01')"""
raw = self.account_no.replace("-", "")
if len(raw) >= 10:
self.acct_prefix = raw[:8]
self.acct_suffix = raw[8:10]
else:
self.acct_prefix = raw
self.acct_suffix = "01"
# ─────────────────────────────────────────
# 토큰 관리
# ─────────────────────────────────────────
def _load_token_from_file(self):
"""재시작 시 파일 캐시에서 토큰 복원"""
try:
if os.path.exists(self._token_cache_file):
with open(self._token_cache_file, encoding="utf-8") as f:
cached = json.load(f)
expires_at = datetime.fromisoformat(cached["expires_at"])
if datetime.now() < expires_at - timedelta(minutes=30):
self._access_token = cached["access_token"]
self._token_expires_at = expires_at
logger.info("KIS 토큰 파일 캐시 복원 완료")
except Exception:
pass
def _save_token_to_file(self):
"""토큰을 파일에 저장 (재시작 시 재사용)"""
try:
os.makedirs(os.path.dirname(self._token_cache_file), exist_ok=True)
with open(self._token_cache_file, "w", encoding="utf-8") as f:
json.dump({
"access_token": self._access_token,
"expires_at": self._token_expires_at.isoformat(),
}, f)
except Exception:
pass
2026-05-14 15:14:50 +09:00
async def get_access_token(self) -> str:
"""액세스 토큰 발급/갱신 (만료 30분 전 자동 갱신)"""
now = datetime.now()
if (self._access_token
and self._token_expires_at
and now < self._token_expires_at - timedelta(minutes=30)):
return self._access_token
url = f"{self.base_url}/oauth2/tokenP"
body = {
"grant_type" : "client_credentials",
"appkey" : self.app_key,
"appsecret" : self.app_secret,
}
async with aiohttp.ClientSession() as session:
async with session.post(url, json=body) as resp:
data = await resp.json()
if "access_token" not in data:
# EGW00133: 분당 1회 제한 — 캐시 토큰이 아직 유효하면 그대로 사용
if data.get("error_code") == "EGW00133" and self._access_token:
logger.warning("KIS 토큰 발급 속도 제한(EGW00133) — 기존 캐시 토큰 유지")
return self._access_token
# 캐시 없으면 60초 대기 후 1회 재시도
if data.get("error_code") == "EGW00133":
logger.warning("KIS 토큰 발급 속도 제한(EGW00133) — 60초 후 재시도")
await asyncio.sleep(60)
async with aiohttp.ClientSession() as session:
async with session.post(url, json=body) as resp:
data = await resp.json()
if "access_token" not in data:
raise RuntimeError(f"토큰 발급 실패: {data}")
2026-05-14 15:14:50 +09:00
self._access_token = data["access_token"]
# 유효기간 24시간
self._token_expires_at = now + timedelta(hours=24)
self._save_token_to_file()
2026-05-14 15:14:50 +09:00
logger.info("KIS 액세스 토큰 발급/갱신 완료")
return self._access_token
# ─────────────────────────────────────────
# REST API 기본 호출
# ─────────────────────────────────────────
async def _request(
self,
method : str,
path : str,
tr_id : str,
params : Optional[Dict] = None,
body : Optional[Dict] = None,
) -> Dict[str, Any]:
"""
KIS REST API 공통 호출
- rate limit 제어 (모드별 요청 간격)
2026-05-14 15:14:50 +09:00
- 토큰 자동 첨부
"""
token = await self.get_access_token()
url = f"{self.base_url}{path}"
headers = {
"content-type" : "application/json; charset=utf-8",
"authorization" : f"Bearer {token}",
"appkey" : self.app_key,
"appsecret" : self.app_secret,
"tr_id" : tr_id,
"custtype" : "P", # 개인
}
async with self._semaphore:
now = time.monotonic()
2026-06-15 18:52:42 +09:00
if now < self._cooldown_until:
await asyncio.sleep(self._cooldown_until - now)
now = time.monotonic()
if self._req_times:
wait = self._request_spacing - (now - self._req_times[-1])
if wait > 0:
await asyncio.sleep(wait)
now = time.monotonic()
2026-05-14 15:14:50 +09:00
self._req_times = [t for t in self._req_times if now - t < 1.0]
if len(self._req_times) >= self._rate_limit:
2026-05-14 15:14:50 +09:00
wait = 1.0 - (now - self._req_times[0])
if wait > 0:
await asyncio.sleep(wait)
now = time.monotonic()
2026-05-14 15:14:50 +09:00
self._req_times.append(time.monotonic())
_timeout = aiohttp.ClientTimeout(total=10)
try:
async with aiohttp.ClientSession(timeout=_timeout) as session:
if method == "GET":
async with session.get(url, headers=headers, params=params) as r:
data = await r.json()
else:
async with session.post(url, headers=headers, json=body) as r:
data = await r.json()
except asyncio.TimeoutError:
raise RuntimeError(f"KIS API 타임아웃 [{tr_id}]")
2026-05-14 15:14:50 +09:00
# 응답 코드 체크
rt_cd = data.get("rt_cd", "")
if rt_cd != "0":
msg = data.get("msg1", "알 수 없는 오류")
2026-06-15 18:52:42 +09:00
if "초당" in msg or "거래건수" in msg or "rate" in msg.lower():
self._cooldown_until = time.monotonic() + max(2.5, self._request_spacing * 2)
2026-05-14 15:14:50 +09:00
logger.error(f"KIS API 오류 [{tr_id}]: {rt_cd} - {msg}")
raise RuntimeError(f"KIS API 오류: {msg}")
return data
# ─────────────────────────────────────────
# 시세 조회
# ─────────────────────────────────────────
async def get_price(self, ticker: str) -> Dict:
"""주식 현재가 조회 (FHKST01010100)"""
data = await self._request(
method = "GET",
path = "/uapi/domestic-stock/v1/quotations/inquire-price",
tr_id = "FHKST01010100",
params = {
"FID_COND_MRKT_DIV_CODE": "J",
"FID_INPUT_ISCD" : ticker,
}
)
o = data["output"]
return {
"ticker" : ticker,
"current" : int(o["stck_prpr"]), # 현재가
"open" : int(o["stck_oprc"]), # 시가
"high" : int(o["stck_hgpr"]), # 고가
"low" : int(o["stck_lwpr"]), # 저가
"prev_close" : int(o["stck_sdpr"]), # 전일 종가
"volume" : int(o["acml_vol"]), # 누적 거래량
"change_pct" : float(o["prdy_ctrt"]), # 등락률
"market_cap" : int(o.get("hts_avls", 0)) * 100_000_000, # 시가총액 (억→원)
}
async def get_ohlcv_daily(self, ticker: str, start: str, end: str) -> list:
"""
주식 기간별 시세 (일봉) - 백테스트/AI 분석용
start, end: 'YYYYMMDD'
"""
data = await self._request(
method = "GET",
path = "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice",
tr_id = "FHKST03010100",
params = {
"FID_COND_MRKT_DIV_CODE": "J",
"FID_INPUT_ISCD" : ticker,
"FID_INPUT_DATE_1" : start,
"FID_INPUT_DATE_2" : end,
"FID_PERIOD_DIV_CODE" : "D", # 일봉
"FID_ORG_ADJ_PRC" : "0",
}
)
result = []
for row in data.get("output2", []):
result.append({
"date" : row["stck_bsop_date"],
"open" : int(row["stck_oprc"]),
"high" : int(row["stck_hgpr"]),
"low" : int(row["stck_lwpr"]),
"close" : int(row["stck_clpr"]),
"volume": int(row["acml_vol"]),
})
return result
# ─────────────────────────────────────────
# 주문
# ─────────────────────────────────────────
async def get_ohlcv_minute(self, ticker: str, hour: str = "153000") -> list:
"""Domestic stock intraday minute bars from KIS."""
data = await self._request(
method="GET",
path="/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice",
tr_id="FHKST03010200",
params={
"FID_ETC_CLS_CODE": "",
"FID_COND_MRKT_DIV_CODE": "J",
"FID_INPUT_ISCD": ticker,
"FID_INPUT_HOUR_1": hour,
"FID_PW_DATA_INCU_YN": "Y",
},
)
def _num(row: dict, *keys: str, default=0):
for key in keys:
value = row.get(key)
if value not in (None, ""):
try:
return int(float(str(value).replace(",", "")))
except (TypeError, ValueError):
return default
return default
result = []
for row in data.get("output2", []) or data.get("output", []):
result.append({
"date": row.get("stck_bsop_date") or row.get("bsop_date") or datetime.now().strftime("%Y%m%d"),
"time": row.get("stck_cntg_hour") or row.get("stck_bsop_hour") or row.get("cntg_hour") or "",
"ticker": ticker,
"open": _num(row, "stck_oprc", "oprc"),
"high": _num(row, "stck_hgpr", "hgpr"),
"low": _num(row, "stck_lwpr", "lwpr"),
"close": _num(row, "stck_prpr", "prpr", "stck_clpr", "clpr"),
"volume": _num(row, "cntg_vol", "acml_vol", "vol"),
})
return sorted(result, key=lambda r: (r["date"], r["time"]))
2026-05-14 15:14:50 +09:00
async def order_buy(
self,
ticker : str,
qty : int,
price : int = 0, # 0 = 시장가
order_type: str = "01", # 01=시장가, 00=지정가
) -> Dict:
"""주식 매수 주문"""
dry_run = os.getenv("DRY_RUN", "true").lower() == "true"
if dry_run:
price_info = await self.get_price(ticker)
current = price_info["current"]
logger.info(f"[DRY_RUN] 매수 {ticker} {qty}주 @ {current:,}")
return {"dry_run": True, "ticker": ticker, "qty": qty, "entry_price": current}
2026-05-14 15:14:50 +09:00
# 모의/실거래 TR 구분
tr_id = "VTTC0802U" if self.is_mock else "TTTC0802U"
data = await self._request(
method = "POST",
path = "/uapi/domestic-stock/v1/trading/order-cash",
tr_id = tr_id,
body = {
"CANO" : self.acct_prefix,
"ACNT_PRDT_CD": self.acct_suffix,
"PDNO" : ticker,
"ORD_DVSN" : order_type,
"ORD_QTY" : str(qty),
"ORD_UNPR" : str(price),
}
)
logger.info(f"매수 주문 완료: {ticker} {qty}")
return data
async def order_sell(
self,
ticker : str,
qty : int,
price : int = 0,
order_type: str = "01",
) -> Dict:
"""주식 매도 주문"""
dry_run = os.getenv("DRY_RUN", "true").lower() == "true"
if dry_run:
price_info = await self.get_price(ticker)
current = price_info["current"]
logger.info(f"[DRY_RUN] 매도 {ticker} {qty}주 @ {current:,}")
return {"dry_run": True, "ticker": ticker, "qty": qty, "exit_price": current}
2026-05-14 15:14:50 +09:00
tr_id = "VTTC0801U" if self.is_mock else "TTTC0801U"
data = await self._request(
method = "POST",
path = "/uapi/domestic-stock/v1/trading/order-cash",
tr_id = tr_id,
body = {
"CANO" : self.acct_prefix,
"ACNT_PRDT_CD": self.acct_suffix,
"PDNO" : ticker,
"ORD_DVSN" : order_type,
"ORD_QTY" : str(qty),
"ORD_UNPR" : str(price),
}
)
logger.info(f"매도 주문 완료: {ticker} {qty}")
return data
# ─────────────────────────────────────────
# 잔고 조회
# ─────────────────────────────────────────
async def get_balance(self) -> Dict:
"""주식 잔고 조회 (보유 종목 + 예수금)"""
tr_id = "VTTC8434R" if self.is_mock else "TTTC8434R"
2026-05-14 15:14:50 +09:00
data = await self._request(
method = "GET",
path = "/uapi/domestic-stock/v1/trading/inquire-balance",
tr_id = tr_id,
params = {
"CANO" : self.acct_prefix,
"ACNT_PRDT_CD" : self.acct_suffix,
"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" : "01",
"CTX_AREA_FK100" : "",
"CTX_AREA_NK100" : "",
}
)
holdings = []
for item in data.get("output1", []):
qty = int(item.get("hldg_qty", "0"))
if qty > 0:
holdings.append({
"ticker" : item["pdno"],
"name" : item["prdt_name"],
"qty" : qty,
"avg_price" : int(item["pchs_avg_pric"].replace(".", "")),
"current" : int(item["prpr"]),
"pnl_pct" : float(item["evlu_pfls_rt"]),
})
cash = int(data["output2"][0].get("dnca_tot_amt", "0")) if data.get("output2") else 0
return {
"holdings" : holdings,
"cash" : cash,
"total_cnt": len(holdings),
}
# ─────────────────────────────────────────
# AI 판단용 수급 데이터
# ─────────────────────────────────────────
async def get_volume_rank(self, top_n: int = 30) -> list:
"""거래량 순위 상위 종목 (AI 판단용)"""
data = await self._request(
method = "GET",
path = "/uapi/domestic-stock/v1/quotations/volume-rank",
tr_id = "FHPST01710000",
params = {
"FID_COND_MRKT_DIV_CODE": "J",
"FID_COND_SCR_DIV_CODE" : "20171",
"FID_INPUT_ISCD" : "0000",
"FID_DIV_CLS_CODE" : "0",
"FID_BLNG_CLS_CODE" : "0",
"FID_TRGT_CLS_CODE" : "111111111",
"FID_TRGT_EXLS_CLS_CODE": "000000",
"FID_INPUT_PRICE_1" : "",
"FID_INPUT_PRICE_2" : "",
"FID_VOL_CNT" : "",
"FID_INPUT_DATE_1" : "",
}
)
result = []
for i, row in enumerate(data.get("output", [])[:top_n]):
result.append({
"rank" : i + 1,
"ticker" : row["mksc_shrn_iscd"],
"name" : row["hts_kor_isnm"],
"volume" : int(row["acml_vol"]),
"change_pct": float(row["prdy_ctrt"]),
})
return result
async def get_foreign_institution_rank(self, top_n: int = 30) -> Dict:
"""외국인/기관 순매수 상위 (AI 판단용) — FHPTJ04400000"""
2026-05-14 15:14:50 +09:00
data = await self._request(
method = "GET",
path = "/uapi/domestic-stock/v1/quotations/foreign-institution-total",
tr_id = "FHPTJ04400000",
2026-05-14 15:14:50 +09:00
params = {
"FID_COND_MRKT_DIV_CODE" : "J",
"FID_COND_SCR_DIV_CODE" : "20171",
"FID_INPUT_ISCD" : "0000",
"FID_DIV_CLS_CODE" : "0",
"FID_BLNG_CLS_CODE" : "0",
"FID_TRGT_CLS_CODE" : "111111111",
"FID_TRGT_EXLS_CLS_CODE" : "000000",
"FID_INPUT_PRICE_1" : "",
"FID_INPUT_PRICE_2" : "",
"FID_VOL_CNT" : "",
"FID_INPUT_DATE_1" : "",
"FID_RANK_SORT_CLS_CODE" : "1", # 순매수량 기준 정렬
"FID_ETC_CLS_CODE" : "0",
2026-05-14 15:14:50 +09:00
}
)
foreign = []
institution = []
for row in data.get("output", []):
ticker = row.get("mksc_shrn_iscd", "")
name = row.get("hts_kor_isnm", "")
try:
frgn_qty = int(row.get("frgn_ntby_qty") or 0)
except (ValueError, TypeError):
frgn_qty = 0
try:
orgn_qty = int(row.get("orgn_ntby_qty") or 0)
except (ValueError, TypeError):
orgn_qty = 0
foreign.append({"ticker": ticker, "name": name, "amount": frgn_qty})
institution.append({"ticker": ticker, "name": name, "amount": orgn_qty})
2026-05-14 15:14:50 +09:00
return {
"foreign" : sorted(foreign, key=lambda x: x["amount"], reverse=True)[:top_n],
"institution": sorted(institution, key=lambda x: x["amount"], reverse=True)[:top_n],
}
# 업종코드 → 업종명 (KOSPI 주요 업종)
_SECTOR_CODES = [
("0001", "KOSPI"),
("0028", "반도체"),
("0150", "2차전지"),
("0018", "전기전자"),
("0011", "의약품"),
("0010", "화학"),
("0016", "철강금속"),
("0017", "기계"),
("0006", "건설업"),
("0027", "금융업"),
("0026", "통신업"),
("0020", "운수창고"),
("0021", "유통업"),
("0009", "음식료품"),
("0024", "전기가스업"),
]
2026-05-14 15:14:50 +09:00
async def get_sector_trend(self) -> list:
"""업종별 등락률 (AI 판단용) — FHPUP02100000 다중 호출"""
2026-05-14 15:14:50 +09:00
result = []
for code, name in self._SECTOR_CODES:
try:
data = await self._request(
method = "GET",
path = "/uapi/domestic-stock/v1/quotations/inquire-index-price",
tr_id = "FHPUP02100000",
params = {
"FID_COND_MRKT_DIV_CODE": "U",
"FID_INPUT_ISCD" : code,
}
)
o = data.get("output", {})
change_pct = float(o.get("bstp_nmix_prdy_ctrt") or 0)
result.append({"sector": name, "change_pct": change_pct})
except Exception as e:
logger.warning(f"업종 지수 조회 실패 [{name}/{code}]: {e}")
2026-05-14 15:14:50 +09:00
return result
# ─────────────────────────────────────────
# WebSocket 클라이언트 (실시간 시세)
# ─────────────────────────────────────────
class KISWebSocket:
"""
KIS 실시간 시세 WebSocket
- 체결가 (H0STCNT0)
- 호가 (H0STASP0)
- VI (H0STVI0)
"""
WS_URL_REAL = "ws://ops.koreainvestment.com:21000"
WS_URL_MOCK = "ws://ops.koreainvestment.com:31000"
def __init__(self, kis_client: KISClient):
self.kis = kis_client
self.ws_url = self.WS_URL_MOCK if kis_client.is_mock else self.WS_URL_REAL
self._ws = None
self._handlers : Dict[str, Callable] = {} # ticker → callback
self._vi_handler: Optional[Callable] = None
self._running = False
def on_price(self, ticker: str, handler: Callable):
"""실시간 체결가 핸들러 등록"""
self._handlers[ticker] = handler
def on_vi(self, handler: Callable):
"""VI 발동 핸들러 등록"""
self._vi_handler = handler
async def subscribe(self, tickers: list):
"""종목 구독 시작"""
token = await self.kis.get_access_token()
# 접속키 발급
async with aiohttp.ClientSession() as session:
resp = await session.post(
f"{self.kis.base_url}/oauth2/Approval",
json={
"grant_type": "client_credentials",
"appkey" : self.kis.app_key,
"secretkey" : self.kis.app_secret,
}
)
key_data = await resp.json()
approval_key = key_data.get("approval_key", "")
async with aiohttp.ClientSession() as session:
async with session.ws_connect(self.ws_url) as ws:
self._ws = ws
self._running = True
logger.info(f"WebSocket 연결 완료: {len(tickers)}종목 구독 시작")
# 종목별 구독 등록
for ticker in tickers:
for tr_id in ["H0STCNT0", "H0STVI0"]:
await ws.send_json({
"header": {
"approval_key": approval_key,
"custtype" : "P",
"tr_type" : "1", # 등록
"content-type": "utf-8",
},
"body": {
"input": {
"tr_id" : tr_id,
"tr_key" : ticker,
}
}
})
logger.info("구독 등록 완료")
# 메시지 수신 루프
async for msg in ws:
if not self._running:
break
if msg.type == aiohttp.WSMsgType.TEXT:
await self._handle_message(msg.data)
elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
logger.error("WebSocket 연결 끊김")
self._running = False
break
async def _handle_message(self, raw: str):
"""WebSocket 메시지 파싱"""
try:
# KIS WebSocket 메시지 포맷: 헤더|바디
if raw.startswith("{"):
# JSON 형식 (시스템 메시지)
return
parts = raw.split("|")
if len(parts) < 4:
return
tr_id = parts[1]
data = parts[3].split("^")
if tr_id == "H0STCNT0":
# 실시간 체결가
ticker = data[0]
price = int(data[2])
volume = int(data[9])
handler = self._handlers.get(ticker)
if handler:
await handler(ticker, price, volume)
elif tr_id == "H0STVI0":
# VI 발동/해제
ticker = data[0]
vi_status = data[1] # 1=발동, 2=해제
ref_price = int(data[5]) if len(data) > 5 else 0
if self._vi_handler:
await self._vi_handler(ticker, vi_status, ref_price)
except Exception as e:
logger.error(f"WebSocket 메시지 파싱 오류: {e}")
async def close(self):
self._running = False
if self._ws:
await self._ws.close()
logger.info("WebSocket 연결 종료")