import requests import json import datetime import os import time import copy from config import get_kis_config import logging # Basic Logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger("KIS_API") class KisApi: def __init__(self): self.config = get_kis_config() self.app_key = self.config.get("app_key") self.app_secret = self.config.get("app_secret") self.account_no = str(self.config.get("account_no", "")).replace("-", "").strip() # 8 digits self.account_prod = str(self.config.get("account_prod", "01")).strip() # 2 digits self.is_paper = self.config.get("is_paper", True) self.htsid = self.config.get("htsid", "") logger.info(f"Initialized KIS API: Account={self.account_no}, Prod={self.account_prod}, Paper={self.is_paper}") if self.is_paper: self.base_url = "https://openapivts.koreainvestment.com:29443" else: self.base_url = "https://openapi.koreainvestment.com:9443" self.token_file = "kis_token.tmp" self.access_token = None self.token_expired = None self.last_req_time = 0 self._auth() def _auth(self): # Clean outdated token file if needed or load existing if os.path.exists(self.token_file): with open(self.token_file, 'r') as f: data = json.load(f) expired = datetime.datetime.strptime(data['expired'], "%Y-%m-%d %H:%M:%S") if expired > datetime.datetime.now(): self.access_token = data['token'] self.token_expired = expired logger.info("Loaded credentials from cache.") return # Request new token url = f"{self.base_url}/oauth2/tokenP" headers = {"content-type": "application/json"} body = { "grant_type": "client_credentials", "appkey": self.app_key, "appsecret": self.app_secret } res = requests.post(url, headers=headers, data=json.dumps(body)) if res.status_code == 200: data = res.json() self.access_token = data['access_token'] self.token_expired = datetime.datetime.strptime(data['access_token_token_expired'], "%Y-%m-%d %H:%M:%S") # Save to file with open(self.token_file, 'w') as f: json.dump({ "token": self.access_token, "expired": data['access_token_token_expired'] }, f) logger.info("Issued new access token.") else: logger.error(f"Auth Failed: {res.text}") raise Exception("Authentication Failed") def get_websocket_key(self): """ Get Approval Key for WebSocket """ url = f"{self.base_url}/oauth2/Approval" headers = {"content-type": "application/json"} body = { "grant_type": "client_credentials", "appkey": self.app_key, "secretkey": self.app_secret } res = requests.post(url, headers=headers, data=json.dumps(body)) if res.status_code == 200: return res.json().get("approval_key") else: logger.error(f"WS Key Failed: {res.text}") return None def _get_header(self, tr_id=None): header = { "Content-Type": "application/json", "authorization": f"Bearer {self.access_token}", "appkey": self.app_key, "appsecret": self.app_secret, "tr_id": tr_id, "custtype": "P" } return header def _request(self, method, path, tr_id=None, x_tr_id_buy=None, x_tr_id_sell=None, **kwargs): """ Centralized request handler with auto-token refresh logic and 500ms throttling. """ # Throttling now = time.time() diff = now - self.last_req_time if diff < 0.5: time.sleep(0.5 - diff) self.last_req_time = time.time() url = f"{self.base_url}{path}" # Determine TR ID curr_tr_id = tr_id if x_tr_id_buy and x_tr_id_sell: pass # Prepare headers headers = self._get_header(curr_tr_id) # Execute Request try: if method.upper() == "GET": res = requests.get(url, headers=headers, **kwargs) else: res = requests.post(url, headers=headers, **kwargs) # Check for Token Expiration (EGW00123) if res.status_code == 200: data = res.json() if isinstance(data, dict): msg_cd = data.get('msg_cd', '') if msg_cd == 'EGW00123': # Expired Token logger.warning("Token expired (EGW00123). Refreshing token and retrying...") # Remove token file if os.path.exists(self.token_file): os.remove(self.token_file) # Re-auth self._auth() # Update headers with new token headers = self._get_header(curr_tr_id) # Retry if method.upper() == "GET": res = requests.get(url, headers=headers, **kwargs) else: res = requests.post(url, headers=headers, **kwargs) return res.json() return data else: logger.error(f"API Request Failed [{res.status_code}]: {res.text}") return None except Exception as e: logger.error(f"Request Exception: {e}") return None def get_current_price(self, code): """ inquire-price """ tr_id = "FHKST01010100" params = { "FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": code } res = self._request("GET", "/uapi/domestic-stock/v1/quotations/inquire-price", tr_id=tr_id, params=params) return res.get('output', {}) if res else None def get_balance(self, source=None): """ inquire-balance Cached for 2 seconds to prevent rate limit (EGW00201) """ # Check cache now = time.time() if hasattr(self, '_balance_cache') and self._balance_cache: last_time, data = self._balance_cache if now - last_time < 2.0: # 2 Seconds TTL return data log_source = f" [Source: {source}]" if source else "" logger.info(f"get_balance{log_source}: Account={self.account_no}, Paper={self.is_paper}") tr_id = "VTTC8434R" if self.is_paper else "TTTC8434R" params = { "CANO": self.account_no, "ACNT_PRDT_CD": self.account_prod, "AFHR_FLPR_YN": "N", "OFL_YN": "", "INQR_DVSN": "02", # 02: By Stock "UNPR_DVSN": "01", "FUND_STTL_ICLD_YN": "N", "FNCG_AMT_AUTO_RDPT_YN": "N", "PRCS_DVSN": "00", "CTX_AREA_FK100": "", "CTX_AREA_NK100": "" } res = self._request("GET", "/uapi/domestic-stock/v1/trading/inquire-balance", tr_id=tr_id, params=params) # Update Cache if success if res and res.get('rt_cd') == '0': self._balance_cache = (now, res) return res def get_overseas_balance(self, exchange="NASD"): """ overseas-stock inquire-balance """ # For overseas, we need to know the exchange code often, but inquire-balance might return all if configured? # Typically requires an exchange code in some params or TR IDs specific to exchange? # Looking at docs: TTTS3012R is for "Overseas Stock Balance" tr_id = "VTTS3012R" if self.is_paper else "TTTS3012R" # Paper env TR ID is tricky, usually V... but let's assume VTTS3012R or JTTT3012R? # Common pattern: Real 'T...' -> Paper 'V...' params = { "CANO": self.account_no, "ACNT_PRDT_CD": self.account_prod, "OVRS_EXCG_CD": exchange, # NASD, NYSE, AMEX, HKS, TSE, etc. "TR_CRCY_CD": "USD", # Transaction Currency "CTX_AREA_FK200": "", "CTX_AREA_NK200": "" } return self._request("GET", "/uapi/overseas-stock/v1/trading/inquire-balance", tr_id=tr_id, params=params) def place_order(self, code, type, qty, price): """ order-cash type: 'buy' or 'sell' """ if self.is_paper: tr_id = "VTTC0012U" if type == 'buy' else "VTTC0011U" else: tr_id = "TTTC0012U" if type == 'buy' else "TTTC0011U" # 00: Limit (Specified Price), 01: Market Price ord_dvsn = "00" if int(price) > 0 else "01" body = { "CANO": self.account_no, "ACNT_PRDT_CD": self.account_prod, "PDNO": code, "ORD_DVSN": ord_dvsn, "ORD_QTY": str(qty), "ORD_UNPR": str(price), "EXCG_ID_DVSN_CD": "KRX", # KRX for Exchange "SLL_TYPE": "01", # 01: Normal Sell "CNDT_PRIC": "" } return self._request("POST", "/uapi/domestic-stock/v1/trading/order-cash", tr_id=tr_id, data=json.dumps(body)) def place_overseas_order(self, code, type, qty, price, market="NASD"): """ overseas-stock order """ # Checks for Paper vs Real and Buy vs Sell # TR_ID might vary by country. Assuming US (NASD, NYSE, AMEX). if self.is_paper: tr_id = "VTTT1002U" if type == 'buy' else "VTTT1006U" else: # US Real: JTTT1002U (Buy), JTTT1006U (Sell) # Note: This TR ID is for US Night Market (Main). tr_id = "JTTT1002U" if type == 'buy' else "JTTT1006U" # Price '0' or empty is usually Market Price, but overseas api often requires specific handling. # US Market Price Order usually uses ord_dvsn="00" and price="0" or empty? # KIS Docs: US Limit="00", Market="00"?? No, usually "00" is Limit. # "32": LOO, "33": LOC, "34": MOO, "35": MOC... # Let's stick to Limit ("00") for now. If price is 0, user might mean Market, but US API requires price for limit. # If user sends 0, let's try to assume "00" (Limit) with price 0 (might fail) or valid Market ("01"? No). # Safe bet: US Market Order is "00" (Limit) with Price 0? No. # Use "00" (Limit) as default. If price is 0, we can't easily do Market on US via standard "01" like domestic. ord_dvsn = "00" # Limit body = { "CANO": self.account_no, "ACNT_PRDT_CD": self.account_prod, "OVRS_EXCG_CD": market, "PDNO": code, "ORD_QTY": str(qty), "OVRS_ORD_UNPR": str(price), "ORD_SVR_DVSN_CD": "0", "ORD_DVSN": ord_dvsn } return self._request("POST", "/uapi/overseas-stock/v1/trading/order", tr_id=tr_id, data=json.dumps(body)) def get_daily_orders(self, start_date=None, end_date=None, expanded_code=None): """ inquire-daily-ccld """ if self.is_paper: tr_id = "VTTC0081R" # 3-month inner else: tr_id = "TTTC0081R" # 3-month inner if not start_date: start_date = datetime.datetime.now().strftime("%Y%m%d") if not end_date: end_date = datetime.datetime.now().strftime("%Y%m%d") params = { "CANO": self.account_no, "ACNT_PRDT_CD": self.account_prod, "INQR_STRT_DT": start_date, "INQR_END_DT": end_date, "SLL_BUY_DVSN_CD": "00", # All "PDNO": "", "CCLD_DVSN": "00", # All (Executed + Unexecuted) "INQR_DVSN": "00", # Reverse Order "INQR_DVSN_3": "00", # All "ORD_GNO_BRNO": "", "ODNO": "", "INQR_DVSN_1": "", "CTX_AREA_FK100": "", "CTX_AREA_NK100": "" } return self._request("GET", "/uapi/domestic-stock/v1/trading/inquire-daily-ccld", tr_id=tr_id, params=params) def get_cancelable_orders(self): """ inquire-psbl-rvsecncl """ tr_id = "VTTC0084R" if self.is_paper else "TTTC0084R" params = { "CANO": self.account_no, "ACNT_PRDT_CD": self.account_prod, "INQR_DVSN_1": "0", # 0: Order No order "INQR_DVSN_2": "0", # 0: All "CTX_AREA_FK100": "", "CTX_AREA_NK100": "" } return self._request("GET", "/uapi/domestic-stock/v1/trading/inquire-psbl-rvsecncl", tr_id=tr_id, params=params) def cancel_order(self, org_no, order_no, qty, is_buy, price="0", total=True): """ order-rvsecncl """ if self.is_paper: tr_id = "VTTC0013U" else: tr_id = "TTTC0013U" rvse_cncl_dvsn_cd = "02" qty_all = "Y" if total else "N" body = { "CANO": self.account_no, "ACNT_PRDT_CD": self.account_prod, "KRX_FWDG_ORD_ORGNO": org_no, "ORGN_ODNO": order_no, "ORD_DVSN": "00", "RVSE_CNCL_DVSN_CD": rvse_cncl_dvsn_cd, "ORD_QTY": str(qty), "ORD_UNPR": str(price), "QTY_ALL_ORD_YN": qty_all, "EXCG_ID_DVSN_CD": "KRX" } return self._request("POST", "/uapi/domestic-stock/v1/trading/order-rvsecncl", tr_id=tr_id, data=json.dumps(body)) # Singleton Instance kis = KisApi()