import httpx from typing import Dict, Optional, Any from app.services.kis_auth import kis_auth from app.core.rate_limiter import global_rate_limiter from app.db.database import SessionLocal from app.db.models import ApiSettings from sqlalchemy import select from app.core.crypto import decrypt_str import logging class KisClient: """ Brokerage Service Interface for KIS API. Implements Section 9 Integration Map. """ # Domestic URLs URL_DOMESTIC_ORDER = "/uapi/domestic-stock/v1/trading/order-cash" URL_DOMESTIC_MODIFY = "/uapi/domestic-stock/v1/trading/order-rvsecncl" URL_DOMESTIC_PRICE = "/uapi/domestic-stock/v1/quotations/inquire-price" URL_DOMESTIC_BALANCE = "/uapi/domestic-stock/v1/trading/inquire-balance" # Overseas URLs URL_OVERSEAS_ORDER = "/uapi/overseas-stock/v1/trading/order" URL_OVERSEAS_MODIFY = "/uapi/overseas-stock/v1/trading/order-rvsecncl" URL_OVERSEAS_PRICE = "/uapi/overseas-price/v1/quotations/price" URL_OVERSEAS_BALANCE = "/uapi/overseas-stock/v1/trading/inquire-balance" def __init__(self): pass self.logger = logging.getLogger(self.__class__.__name__) async def _get_settings(self): async with SessionLocal() as session: stmt = select(ApiSettings).where(ApiSettings.id == 1) result = await session.execute(stmt) return result.scalar_one_or_none() async def _call_api(self, method: str, url_path: str, tr_id: str, params: Dict = None, data: Dict = None) -> Dict: """ Common API Caller with Rate Limiting and Auth. """ # 1. Rate Limit await global_rate_limiter.wait() # 2. Get Token & Base URL # Assuming Real Environment for now. TODO: Support Virtual base_url = kis_auth.BASE_URL_REAL token = await kis_auth.get_access_token() settings = await self._get_settings() if not settings: raise ValueError("Settings not found") # 3. Headers headers = { "Content-Type": "application/json", "authorization": f"Bearer {token}", "appkey": decrypt_str(settings.appKey), "appsecret": decrypt_str(settings.appSecret), "tr_id": tr_id, "tr_cont": "", "custtype": "P" } full_url = f"{base_url}{url_path}" # self.logger.debug(f"API Calling: {method} {url_path} (TR_ID: {tr_id})") async with httpx.AsyncClient() as client: if method == "GET": resp = await client.get(full_url, headers=headers, params=params) else: resp = await client.post(full_url, headers=headers, json=data) # TODO: Handle 401 Re-Auth Logic here (catch, clear token, retry) resp.raise_for_status() return resp.json() # ----------------------------- # 1. Current Price # ----------------------------- async def get_current_price(self, market: str, code: str) -> float: if market == "Domestic": # TR_ID: FHKST01010100 params = { "FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": code } res = await self._call_api("GET", self.URL_DOMESTIC_PRICE, "FHKST01010100", params=params) return float(res['output']['stck_prpr']) elif market == "Overseas": # TR_ID: HHDFS00000300 # Need Exchange Code (e.g., NASD). Assuming NASD for generic US or mapped. # Ideally code should be 'NASD:AAPL' or separate excg param. # For now assuming 'NASD' if not provided implicitly. # Or code is just 'AAPL'. excg = "NASD" # Default params = { "AUTH": "", "EXCD": excg, "SYMB": code } res = await self._call_api("GET", self.URL_OVERSEAS_PRICE, "HHDFS00000300", params=params) return float(res['output']['last']) return 0.0 # ----------------------------- # 2. Balance # ----------------------------- async def get_balance(self, market: str) -> Dict: settings = await self._get_settings() acc_no = decrypt_str(settings.accountNumber) # acc_no is 8 digits. Split? "500xxx-01" -> 500xxx, 01 if '-' in acc_no: cano, prdt = acc_no.split('-') else: cano = acc_no[:8] prdt = acc_no[8:] if market == "Domestic": # TR_ID: TTTC8434R (Real) params = { "CANO": cano, "ACNT_PRDT_CD": prdt, "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": "" } res = await self._call_api("GET", self.URL_DOMESTIC_BALANCE, "TTTC8434R", params=params) return res elif market == "Overseas": # TR_ID: TTTS3012R (Real) params = { "CANO": cano, "ACNT_PRDT_CD": prdt, "OVRS_EXCG_CD": "NASD", # Default "TR_CRCY_CD": "USD", "CTX_AREA_FK200": "", "CTX_AREA_NK200": "" } res = await self._call_api("GET", self.URL_OVERSEAS_BALANCE, "TTTS3012R", params=params) return res return {} # ----------------------------- # 3. Order # ----------------------------- async def place_order(self, market: str, side: str, code: str, quantity: int, price: float) -> Dict: """ side: 'buy' or 'sell' price: 0 for Market? KIS logic varies. """ settings = await self._get_settings() acc_no_str = decrypt_str(settings.accountNumber) if '-' in acc_no_str: cano, prdt = acc_no_str.split('-') else: cano = acc_no_str[:8] prdt = acc_no_str[8:] if market == "Domestic": # TR_ID: TTT 0802U (Buy), 0801U (Sell) -> using sample 0012U/0011U # 0012U (Buy), 0011U (Sell) tr_id = "TTTC0012U" if side == "buy" else "TTTC0011U" data = { "CANO": cano, "ACNT_PRDT_CD": prdt, "PDNO": code, "ORD_DVSN": "00", # Limit (00). 01=Market "ORD_QTY": str(quantity), "ORD_UNPR": str(int(price)), # Cash Order requires integer price string } self.logger.info(f"Ordering Domestic: {side} {code} {quantity}qty @ {price}") return await self._call_api("POST", self.URL_DOMESTIC_ORDER, tr_id, data=data) elif market == "Overseas": # TR_ID: TTTT1002U (US Buy), TTTT1006U (US Sell) # Assuming US (NASD) tr_id = "TTTT1002U" if side == "buy" else "TTTT1006U" data = { "CANO": cano, "ACNT_PRDT_CD": prdt, "OVRS_EXCG_CD": "NASD", "PDNO": code, "ORD_QTY": str(quantity), "OVRS_ORD_UNPR": str(price), "ORD_SVR_DVSN_CD": "0", "ORD_DVSN": "00" # Limit } return await self._call_api("POST", self.URL_OVERSEAS_ORDER, tr_id, data=data) async def modify_order(self, market: str, order_no: str, code: str, quantity: int, price: float, type: str = "00", cancel: bool = False) -> Dict: """ Cancel or Modify Order. cancel=True -> Cancel """ settings = await self._get_settings() acc_no_str = decrypt_str(settings.accountNumber) if '-' in acc_no_str: cano, prdt = acc_no_str.split('-') else: cano = acc_no_str[:8] prdt = acc_no_str[8:] if market == "Domestic": # TR_ID: TTTC0803U (Modify/Cancel) data = { "CANO": cano, "ACNT_PRDT_CD": prdt, "KRX_FWDG_ORD_ORGNO": "", # Exchange Node? Usually empty or "00950" "ORGN_ODNO": order_no, "ORD_DVSN": type, "RVSE_CNCL_DVSN_CD": "02" if cancel else "01", # 01: Modify, 02: Cancel "ORD_QTY": str(quantity), "ORD_UNPR": str(int(price)), "QTY_ALL_ORD_YN": "Y" if quantity == 0 else "N", # 0 means cancel all? } # Note: KRX_FWDG_ORD_ORGNO is tricky. Usually 5 digit branch code. Defaulting to "" might fail. # Using '06010' (Online) or leaving blank depending on API. # KIS API Doc: "주문상태조회"에서 얻은 ORGNO 사용해야 함. # For this impl, we assume user knows or simple default. return await self._call_api("POST", self.URL_DOMESTIC_MODIFY, "TTTC0803U", data=data) elif market == "Overseas": # MCCL: TTTT1004U data = { "CANO": cano, "ACNT_PRDT_CD": prdt, "OVRS_EXCG_CD": "NASD", "PDNO": code, "ORGN_ODNO": order_no, "RVSE_CNCL_DVSN_CD": "02" if cancel else "01", "ORD_QTY": str(quantity), "OVRS_ORD_UNPR": str(price), } return await self._call_api("POST", self.URL_OVERSEAS_MODIFY, "TTTT1004U", data=data) kis_client = KisClient()