""" kis_trader/execution/kis_client.py — 한국투자증권 REST 클라이언트 (통합) ========================================================================== 기존 kis_scalping_ver2.KISClient / kis_short_ver3.KISClient 의 공통 기능을 SafeRequest 기반으로 통합. 두 봇이 독립 토큰을 발급받아 충돌나던 문제를 `kis_token_manager` 위임으로 해결. 제공 기능 (매매 봇이 쓰는 최소 세트): - inquire_price : 현재가 - get_account_balance : 계좌 잔고 (output1=종목별, output2=예수금) - get_broker_holdings_map : 잔고 → {code: {qty=보유, hldg_qty, ord_psbl_qty=매도가능, ...}} - get_cancelable_sell_map : 정정취소가능주문조회 → {code: {psbl_qty, odno, ...}} - get_minute_chart : 분봉 (갭보정용) - get_daily_chart : 일봉 (대/중/소형주 판정용) - get_orderbook : 호가 잔량 (익절 지정가용) - buy_order / sell_order : 매수·매도 주문 (ODNO 반환) - get_execution_by_odno : 주문번호로 체결 확인 """ from __future__ import annotations import datetime import logging from datetime import datetime as dt from pathlib import Path from typing import Dict, List, Optional import pandas as pd from ..utils.env import ( get_env_bool, get_env_float, get_env_from_db, get_env_int, ) from ..utils.logger import get_logger from ..utils.request_handler import SafeRequest logger = get_logger("kis_trader.kis_client") def log_kis_api_response( tag: str, j: Optional[dict], *, http_status: Optional[int] = None, tr_id: Optional[str] = None, extra: Optional[str] = None, level: str = "warning", ) -> None: """한투 REST rt_cd/msg_cd/msg1 — 디버그·reconcile용 통일 로그.""" if not isinstance(j, dict): j = {} rt_cd = str(j.get("rt_cd") or "") msg_cd = str(j.get("msg_cd") or "") msg1 = str(j.get("msg1") or "") parts = [ f"[{tag}]", f"rt_cd={rt_cd or '-'}", f"msg_cd={msg_cd or '-'}", f"msg1={msg1 or '-'}", ] if http_status is not None: parts.append(f"http={http_status}") if tr_id: parts.append(f"tr_id={tr_id}") if extra: parts.append(str(extra)) line = " ".join(parts) if level == "info": logger.info(line) elif level == "error": logger.error(line) else: logger.warning(line) # 토큰 캐시 경로 (프로젝트 루트와 동일 위치 공유) _PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent def parse_kis_int_qty(v) -> int: """한투 잔고/주문 수량 문자열 → int. 콤마·공백·빈값 안전.""" try: s = str(v if v is not None else "0").replace(",", "").strip() if not s: return 0 return int(float(s)) except Exception: return 0 def holdings_row_from_balance_item(it: dict) -> Optional[Dict]: """ inquire-balance output1 한 행 → 보유/매도가능 분리. - hldg_qty : 보유수량 (아직 내 주식) - ord_psbl_qty : 주문가능수량 = 매도가능 (미체결 매도가 잠그면 0) - qty : 하위호환 = 보유수량 (매도가능과 섞지 않음) 둘 다 0이면 None (맵에서 제외). 매도가능만 0이고 보유>0 이면 행을 남긴다. """ if not isinstance(it, dict): return None code = str(it.get("pdno") or it.get("PDNO") or "").strip() if not code: return None hldg = parse_kis_int_qty(it.get("hldg_qty") or it.get("HLDG_QTY")) psbl = parse_kis_int_qty(it.get("ord_psbl_qty") or it.get("ORD_PSBL_QTY")) if hldg <= 0 and psbl <= 0: return None avg_raw = it.get("pchs_avg_pric") or it.get("PCHS_AVG_PRIC") or 0 try: avg_price = abs(float(str(avg_raw).replace(",", ""))) except Exception: avg_price = 0.0 name = (it.get("prdt_name") or it.get("PRDT_NAME") or code).strip() evlu_raw = it.get("evlu_amt") or it.get("EVLU_AMT") or 0 try: evlu_amt = float(str(evlu_raw).replace(",", "")) except Exception: evlu_amt = 0.0 prpr_raw = it.get("prpr") or it.get("PRPR") or 0 try: current_price = abs(float(str(prpr_raw).replace(",", ""))) except Exception: current_price = 0.0 thdt_sll = parse_kis_int_qty(it.get("thdt_sll_qty") or it.get("THDT_SLL_QTY")) thdt_buy = parse_kis_int_qty(it.get("thdt_buyqty") or it.get("THDT_BUYQTY")) return { "qty": hldg, "hldg_qty": hldg, "ord_psbl_qty": psbl, "sellable_qty": psbl, "thdt_sll_qty": thdt_sll, "thdt_buyqty": thdt_buy, "avg_price": avg_price, "name": name, "evlu_amt": evlu_amt, "current_price": current_price, } def broker_row_hldg_qty(row: Optional[dict]) -> int: """잔고 맵 행의 보유수량. hldg_qty 우선, 없으면 qty.""" if not row: return 0 if "hldg_qty" in row: return parse_kis_int_qty(row.get("hldg_qty")) return parse_kis_int_qty(row.get("qty")) def broker_row_sellable_qty(row: Optional[dict]) -> int: """잔고 맵 행의 매도가능수량. 키 없으면 보유수량(구맵 호환).""" if not row: return 0 if "ord_psbl_qty" in row: return parse_kis_int_qty(row.get("ord_psbl_qty")) if "sellable_qty" in row: return parse_kis_int_qty(row.get("sellable_qty")) return broker_row_hldg_qty(row) def broker_row_still_held(row: Optional[dict]) -> bool: """보유>0 또는 매도가능>0 이면 계좌에 아직 주식이 있다.""" return broker_row_hldg_qty(row) > 0 or broker_row_sellable_qty(row) > 0 def cancelable_remainder_qty(cmap: Optional[Dict[str, Dict]], code: str) -> int: """정정취소가능 맵에서 해당 종목 잔량. cmap is None 이면 0 (호출부가 API실패를 먼저 가릴 것).""" if not cmap or not code: return 0 row = cmap.get(str(code).strip()) or {} return parse_kis_int_qty(row.get("psbl_qty")) class KISClient(SafeRequest): """한국투자증권 REST API 통합 클라이언트. SafeRequest 상속.""" REAL_BASE = "https://openapi.koreainvestment.com:9443" MOCK_BASE = "https://openapivts.koreainvestment.com:29443" def __init__( self, *, mock: Optional[bool] = None, app_key: Optional[str] = None, app_secret: Optional[str] = None, account_no: Optional[str] = None, account_code: Optional[str] = None, ): # ── 도메인별 REST 최소 호출 간격 ────────────────────────────── # 한투 유량(계좌·앱키 단위): 실전 18건/초, 모의 1건/초. # · 모의 도메인(거래) → KIS_MIN_INTERVAL_SEC_MOCK (폴백 KIS_MIN_INTERVAL_SEC=0.22) # ※ 모의 거래 REST는 429 로그 근거가 없어 0.22 유지. 한투 공지 1건/초 엄수가 필요하면 1.0 으로. # · 실전 도메인(시세·운영 통합) → KIS_MIN_INTERVAL_SEC_REAL (기본 0.12초 ≈ 8건/초, 18 한도 여유) # 매 호출 간격은 _current_min_interval() 가 env 핫리로드(1초 TTL)로 재해석 → 재시작 없이 반영. mock_flag = bool(get_env_bool("KIS_MOCK", True)) if mock is None else bool(mock) _legacy_interval = get_env_float("KIS_MIN_INTERVAL_SEC", 0.22) if mock_flag: _min_interval = get_env_float("KIS_MIN_INTERVAL_SEC_MOCK", _legacy_interval) else: _min_interval = get_env_float("KIS_MIN_INTERVAL_SEC_REAL", min(_legacy_interval, 0.12)) super().__init__( min_interval_sec=_min_interval, max_retries=get_env_int("KIS_REST_MAX_RETRIES", 5), backoff_base=1.0, backoff_cap=get_env_float("KIS_REST_BACKOFF_CAP_SEC", 8.0), timeout_sec=get_env_float("KIS_REST_TIMEOUT_SEC", 10.0), ) self.mock = mock_flag # 핫리로드 캐시: env 매 호출 조회 비용 줄이려 1초 TTL (재시작 없이 반영) self._interval_cache: float = _min_interval self._interval_cache_ts: float = 0.0 if self.mock: self.app_key = app_key or get_env_from_db("KIS_APP_KEY_MOCK", "") self.app_secret = app_secret or get_env_from_db("KIS_APP_SECRET_MOCK", "") self.account_no = account_no or get_env_from_db("KIS_ACCOUNT_NO_MOCK", "") self.account_code = account_code or get_env_from_db( "KIS_ACCOUNT_CODE_MOCK", "01" ) else: self.app_key = app_key or get_env_from_db("KIS_APP_KEY_REAL", "") self.app_secret = app_secret or get_env_from_db("KIS_APP_SECRET_REAL", "") self.account_no = account_no or get_env_from_db("KIS_ACCOUNT_NO_REAL", "") self.account_code = account_code or get_env_from_db( "KIS_ACCOUNT_CODE_REAL", "01" ) self.base_url = self.MOCK_BASE if self.mock else self.REAL_BASE self._token: Optional[str] = None # 마지막 주문 실패 원인 (매매불가, 잔고없음 등 분기) self._last_order_msg_cd: Optional[str] = None self._last_order_msg1: Optional[str] = None self._last_sell_msg_cd: Optional[str] = None self._last_sell_msg1: Optional[str] = None self._last_cancel_rt_cd: Optional[str] = None self._last_cancel_msg_cd: Optional[str] = None self._last_cancel_msg1: Optional[str] = None self._inquire_price_cache: dict = {} # 같은 요청에서 holdings_map + 계좌요약이 inquire-balance 를 두 번 치지 않게 self._last_inquire_balance: Optional[dict] = None self._init_token() # ------------------------------------------------------------------ # 토큰 # ------------------------------------------------------------------ def _current_min_interval(self) -> float: """REST 최소 호출 간격 — 도메인(mock/real)별 env 핫리로드 (1초 TTL 캐시). 운영설정에서 KIS_MIN_INTERVAL_SEC_MOCK/REAL 변경 시 재시작 없이 반영. """ import time as _t now = _t.time() if now - self._interval_cache_ts < 1.0: return self._interval_cache legacy = get_env_float("KIS_MIN_INTERVAL_SEC", 0.22) if self.mock: val = get_env_float("KIS_MIN_INTERVAL_SEC_MOCK", legacy) else: val = get_env_float("KIS_MIN_INTERVAL_SEC_REAL", min(legacy, 0.12)) val = max(0.0, float(val)) self._interval_cache = val self._interval_cache_ts = now self.min_interval_sec = val # get_throttle_stats 표시 일관성 return val def _init_token(self) -> None: """kis_token_manager 경로로만 (세션커버 시 재사용, 파일 잠금·1일1회 준수).""" try: from kis_token_manager import KisTokenManager, ensure_token # 해당 모드만: 세션미커버/만료일 때만 tokenP. 양쪽은 기동 ensure_both. ensure_token(self.mock) self._token = KisTokenManager.instance(is_mock=self.mock).get_token() except Exception as e: logger.warning("kis_token_manager 연동 실패: %s", e) self._token = None def _refresh_token_if_needed(self) -> None: """호출 직전: 만료 시에만 비상 갱신 (장중 10분 선제 발급 없음).""" try: from kis_token_manager import KisTokenManager fresh = KisTokenManager.instance(is_mock=self.mock).get_token() if fresh: self._token = fresh except Exception: pass # ------------------------------------------------------------------ # 저수준 GET/POST # ------------------------------------------------------------------ def _headers(self, tr_id: str, is_post: bool = False, tr_cont: str = "") -> dict: h = { "authorization": f"Bearer {self._token}", "appkey": self.app_key, "appsecret": self.app_secret, "tr_id": tr_id, "custtype": "P", } # 연속조회(페이징) 시에만 tr_cont 헤더 추가 — 빈값이면 미추가(기존 동작 100% 보존) if tr_cont: h["tr_cont"] = tr_cont if is_post: h["content-type"] = "application/json; charset=utf-8" return h def _get(self, path: str, tr_id: str, params: dict, tr_cont: str = ""): self._refresh_token_if_needed() return self.get( self.base_url + path, headers=self._headers(tr_id, tr_cont=tr_cont), params=params, ) def _post(self, path: str, tr_id: str, body: dict): self._refresh_token_if_needed() return self.post( self.base_url + path, headers=self._headers(tr_id, is_post=True), json_body=body, ) # ------------------------------------------------------------------ # 시세 # ------------------------------------------------------------------ def get_orderbook(self, code: str) -> Optional[dict]: """호가 잔량 [v1_국내주식-009] — 익절 시 매수호가 지정가 판단용.""" try: r = self._get( "/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn", "FHKST01010200", { "FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": code, }, ) if r.status_code != 200: return None j = r.json() if j.get("rt_cd") != "0": return None return j.get("output") except Exception as e: logger.debug("호가 조회 실패(%s): %s", code, e) return None def inquire_price(self, code: str) -> Optional[dict]: """현재가 [v1_국내주식-007] (60초 캐싱)""" now = __import__("time").time() if code in self._inquire_price_cache: ts, data = self._inquire_price_cache[code] if now - ts < 60.0: return data r = self._get( "/uapi/domestic-stock/v1/quotations/inquire-price", "FHKST01010100", {"FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": code}, ) if r.status_code != 200: return None j = r.json() out = j.get("output") if j.get("rt_cd") == "0" else None if out: self._inquire_price_cache[code] = (now, out) return out def inquire_stock_name(self, code: str) -> Optional[str]: """국내 종목명 [주식기본조회 CTPF1002R]. 약명(prdt_abrv_name) 우선.""" code = str(code or "").strip() if not code: return None try: r = self._get( "/uapi/domestic-stock/v1/quotations/search-stock-info", "CTPF1002R", {"PRDT_TYPE_CD": "300", "PDNO": code}, ) if r.status_code != 200: return None j = r.json() if j.get("rt_cd") != "0": return None out = j.get("output") or {} if isinstance(out, list): out = out[0] if out else {} name = ( str(out.get("prdt_abrv_name") or out.get("prdt_name") or "").strip() ) return name or None except Exception as e: logger.debug("종목명 조회 실패(%s): %s", code, e) return None def inquire_multi_price(self, codes: List[str]) -> Dict[str, dict]: """ 관심종목(멀티종목) 시세조회 [국내주식-205] — FHKST11300006. 한 번 호출에 **최대 30종목**의 현재가 스냅샷을 받는다(봉 시계열 아님). UPDOWN(박스권) REST 피드의 '현재가/하단근접/청산감시' 트리거 전용 — 종목당 inquire_price 30콜 → 1콜로 대폭 절약(REST 부하·429 방지). ※ HTS 관심그룹 등록과 무관하게 임의 코드 리스트를 직접 넣어 조회 가능. ※ 30종목 초과 입력 시 앞 30개만 사용(상위 호출자가 배치로 끊어 호출). Returns: {code: output_row, ...} output_row 주요 키(원본 KIS 필드명 유지): inter_shrn_iscd(종목코드), inter2_prpr(현재가), inter2_oprc/hgpr/lwpr(당일 시/고/저), inter2_prdy_clpr(전일종가), prdy_ctrt(전일대비율), acml_vol(누적거래량) """ result: Dict[str, dict] = {} clean = [str(c).strip() for c in (codes or []) if str(c or "").strip()] if not clean: return result # 30종목 초과는 앞 30개만 (상위에서 배치 분할 권장) clean = clean[:30] params: Dict[str, str] = {} for i, code in enumerate(clean, start=1): params[f"FID_COND_MRKT_DIV_CODE_{i}"] = "J" # J=KRX params[f"FID_INPUT_ISCD_{i}"] = code try: r = self._get( "/uapi/domestic-stock/v1/quotations/intstock-multprice", "FHKST11300006", params, ) if r.status_code != 200: logger.debug("멀티시세 HTTP %s", r.status_code) return result j = r.json() if j.get("rt_cd") != "0": logger.debug("멀티시세 rt_cd=%s msg=%s", j.get("rt_cd"), j.get("msg1")) return result for row in (j.get("output") or []): rc = str(row.get("inter_shrn_iscd", "") or "").strip() if rc: result[rc] = row except Exception as e: logger.debug("멀티시세 조회 실패: %s", e) return result def inquire_index_price(self, index_code: str = "0001") -> Optional[dict]: """ 국내 업종지수 현재지수 [v1_국내주식-066] — MarketGuard 전용. index_code: - "0001": KOSPI 종합 - "1001": KOSDAQ 종합 - "2001": KOSPI200 반환 dict 주요 키: - bstp_nmix_prpr : 현재 지수 - bstp_nmix_prdy_clpr : 전일 종가 - bstp_nmix_prdy_vrss : 전일 대비 - bstp_nmix_prdy_ctrt : 전일 대비 등락률(%) FID_COND_MRKT_DIV_CODE = 'U' (업종/지수). 종목조회의 'J' 와 다름. """ r = self._get( "/uapi/domestic-stock/v1/quotations/inquire-index-price", "FHPUP02100000", {"FID_COND_MRKT_DIV_CODE": "U", "FID_INPUT_ISCD": index_code}, ) if r.status_code != 200: return None j = r.json() return j.get("output") if j.get("rt_cd") == "0" else None def get_minute_chart( self, code: str, period: str = "1", limit: int = 100 ) -> pd.DataFrame: """ 분봉 [v1_국내주식-017] — 갭 보정용. ⚠️ 한투 FHKST03010200 스펙: * 1분봉만 지원 (period 인자는 상위 호출자 호환용으로만 유지) * 1회 호출 ≤ 30봉, 응답은 최신→과거 역순 * FID_INPUT_HOUR_1 = HHMMSS 형식의 "조회 커서 시각" * 커서를 뒤로 밀며 페이지네이션해 limit 개수만큼 수집 (holding_bot.fetch_and_store_min_candles 과 동일 패턴 — 검증된 파라미터) """ path = "/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice" tr_id = "FHKST03010200" try: # 페이지네이션 커서 — 장중이면 현재 시각, 장 마감 후엔 15:30:00 now = dt.now() if now.hour < 9: cursor_dt = now.replace(hour=15, minute=30, second=0, microsecond=0) \ - datetime.timedelta(days=1) elif now.hour > 15 or (now.hour == 15 and now.minute >= 30): cursor_dt = now.replace(hour=15, minute=30, second=0, microsecond=0) else: cursor_dt = now rows: list = [] seen: set = set() # 중복 제거용 (time 키) # 최대 페이지 수 — 한 페이지 ≈ 30봉 기준 여유 있게 계산 max_pages = max(1, (int(limit) // 25) + 2) for _ in range(max_pages): params = { "FID_ETC_CLS_CODE": "", "FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": code, # HHMMSS 커서 (역순으로 이동) "FID_INPUT_HOUR_1": cursor_dt.strftime("%H%M%S"), "FID_PW_DATA_INCU_YN": "Y", # 과거 날짜 포함 } r = self._get(path, tr_id, params) if r.status_code != 200: break j = r.json() if j.get("rt_cd") != "0": break out = j.get("output2", []) if not out: break page_last_dt = None for it in out: try: d = str(it.get("stck_bsop_date", "") or "") t = str(it.get("stck_cntg_hour", "") or "000000") if len(d) < 8 or len(t) < 6: continue # YYYYMMDDHHMM (12자리) — CandleAggregator.fill_gap_from_rest 호환 tkey = d + t[:4] if tkey in seen: continue seen.add(tkey) rows.append({ "time": tkey, "open": abs(float(it.get("stck_oprc", 0))), "high": abs(float(it.get("stck_hgpr", 0))), "low": abs(float(it.get("stck_lwpr", 0))), "close": abs(float(it.get("stck_clpr", 0))), "volume": int(it.get("acml_vol", 0) or 0), }) # 절대 시각 파싱 → 다음 커서 계산용 try: page_last_dt = dt.strptime(d + t, "%Y%m%d%H%M%S") except Exception: pass except Exception: continue if len(rows) >= int(limit) or page_last_dt is None: break # 다음 페이지: 마지막(가장 오래된) 레코드 시각 - 1분 cursor_dt = page_last_dt - datetime.timedelta(minutes=1) # 장외시간으로 넘어가면 중단 (더 과거는 한투 FHKST03010200이 안 줌) if cursor_dt.hour < 9 or (cursor_dt.hour == 15 and cursor_dt.minute >= 30) \ or cursor_dt.hour > 15: break if not rows: return pd.DataFrame() df = pd.DataFrame(rows).sort_values("time").reset_index(drop=True) return df.tail(int(limit)) except Exception as e: logger.debug("분봉 조회 실패(%s): %s", code, e) return pd.DataFrame() def get_daily_chart(self, code: str, limit: int = 10) -> pd.DataFrame: """일봉 [v1_국내주식-017] — 거래대금(대/중/소형) 판정용.""" path = "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice" tr_id = "FHKST03010100" try: end_dt = dt.now() start_dt = end_dt - datetime.timedelta(days=limit + 30) r = self._get( path, tr_id, { "FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": code, "FID_INPUT_DATE_1": start_dt.strftime("%Y%m%d"), "FID_INPUT_DATE_2": end_dt.strftime("%Y%m%d"), "FID_PERIOD_DIV_CODE": "D", "FID_ORG_ADJ_PRC": "1", }, ) if r.status_code != 200: return pd.DataFrame() j = r.json() if j.get("rt_cd") != "0": return pd.DataFrame() out = j.get("output2", []) if not out: return pd.DataFrame() rows = [] for it in out[:limit]: try: rows.append({ "date": str(it.get("stck_bsop_date", "") or ""), "open": abs(float(it.get("stck_oprc", 0))), "high": abs(float(it.get("stck_hgpr", 0))), "low": abs(float(it.get("stck_lwpr", 0))), "close": abs(float(it.get("stck_clpr", 0))), "volume": int(it.get("acml_vol", 0)), }) except Exception: continue if not rows: return pd.DataFrame() return pd.DataFrame(rows).sort_values("date").reset_index(drop=True) except Exception as e: logger.debug("일봉 조회 실패(%s): %s", code, e) return pd.DataFrame() # ------------------------------------------------------------------ # 랭킹 (거래량/거래대금/체결강도/등락률 상위 — FHPST01710000) # ------------------------------------------------------------------ # volume-rank API 는 tr_cont 를 받지 않는다. 1회 호출 ≤ 30~50건 반환. # FID_BLNG_CLS_CODE: # 0=평균거래량, 1=거래증가율, 2=평균거래회전율, # 3=거래금액순, 4=등락률(상승), 5=등락률(하락), 6=체결강도순 @staticmethod def _is_valid_stock_for_rank(name: str, code: str) -> bool: """스팩/ETN/레버리지/인버스/우선주 등 비본주 제외 (utils.non_stock 공용).""" if not code: return False code = code.strip() name = (name or "").strip() if len(code) != 6: return False from kis_trader.utils.non_stock import is_non_stock return not is_non_stock(name, code) def _filter_rank_rows(self, rows: list) -> list: if not rows: return [] out = [] for it in rows: code = ( it.get("mksc_shrn_iscd") or it.get("stk_cd") or it.get("code") or "" ).strip() name = ( it.get("hts_kor_isnm") or it.get("stk_nm") or it.get("prst_name") or "" ).strip() if self._is_valid_stock_for_rank(name, code): out.append(it) return out def _fetch_volume_rank( self, *, market: str = "J", blng_cls_code: str = "0", limit: int = 100, exclude_non_stock: bool = True, ) -> List[dict]: """FHPST01710000 1회 호출 → rows.""" path = "/uapi/domestic-stock/v1/quotations/volume-rank" tr_id = "FHPST01710000" params = { "FID_COND_MRKT_DIV_CODE": market, "FID_COND_SCR_DIV_CODE": "20171", "FID_INPUT_ISCD": "0000", "FID_DIV_CLS_CODE": "0", "FID_BLNG_CLS_CODE": blng_cls_code, "FID_TRGT_CLS_CODE": "111111111", "FID_TRGT_EXLS_CLS_CODE": "0000000000", "FID_INPUT_PRICE_1": "0", "FID_INPUT_PRICE_2": "0", "FID_VOL_CNT": "0", "FID_INPUT_DATE_1": "", } try: r = self._get(path, tr_id, params) if r.status_code != 200: logger.debug("랭킹 HTTP %s (blng=%s)", r.status_code, blng_cls_code) return [] j = r.json() if j.get("rt_cd") != "0": logger.debug( "랭킹 실패 rt_cd=%s msg=%s", j.get("rt_cd"), j.get("msg1"), ) return [] rows = j.get("output") or [] if isinstance(rows, dict): rows = [rows] if exclude_non_stock: rows = self._filter_rank_rows(rows) return rows[:limit] except Exception as e: logger.debug("랭킹 조회 예외 (blng=%s): %s", blng_cls_code, e) return [] def get_volume_rank( self, *, market: str = "J", limit: int = 100, exclude_non_stock: bool = True, ) -> List[dict]: """거래량 상위.""" return self._fetch_volume_rank( market=market, blng_cls_code="0", limit=limit, exclude_non_stock=exclude_non_stock, ) def get_trading_value_rank( self, *, market: str = "J", limit: int = 100, exclude_non_stock: bool = True, ) -> List[dict]: """거래대금 상위.""" return self._fetch_volume_rank( market=market, blng_cls_code="3", limit=limit, exclude_non_stock=exclude_non_stock, ) def get_execution_strength_rank( self, *, market: str = "J", limit: int = 100, exclude_non_stock: bool = True, ) -> List[dict]: """체결강도 상위 (매수세 강한 종목).""" return self._fetch_volume_rank( market=market, blng_cls_code="6", limit=limit, exclude_non_stock=exclude_non_stock, ) def get_price_change_rank( self, *, market: str = "J", sort_type: str = "up", limit: int = 100, exclude_non_stock: bool = True, ) -> List[dict]: """등락률 상위. sort_type='up' 상승, 'down' 하락(낙폭).""" blng = "5" if str(sort_type).lower() in ("down", "decline", "2") else "4" rows = self._fetch_volume_rank( market=market, blng_cls_code=blng, limit=limit, exclude_non_stock=exclude_non_stock, ) if rows or blng == "5": return rows # 일부 계정에서 4/5 미지원 → 거래량 fallback (상승만) return self.get_volume_rank( market=market, limit=limit, exclude_non_stock=exclude_non_stock, ) def get_execution_strength_map( self, *, market: str = "J", limit: int = 200, ) -> Dict[str, float]: """ 체결강도 상위 조회 → {code: cntr_str(체결강도값)} 맵. ⚠️ 검증된 제약 (실호출 확인, 2025-04 기준): * KIS volume-rank(FHPST01710000) 응답에 **체결강도 값 필드가 없음**. 응답은 거래량/거래대금/회전율만 포함. 따라서 본 맵은 0.0 으로 채워짐. * 모의투자 서버에서는 ``blng=6`` 정렬이 체결강도 순이 아닌 종목코드 순 으로 반환됨(실계좌는 정렬이 정상일 가능성 높음). * ✅ **정확한 실시간 체결강도**는 WebSocket ``H0STCNT0`` 의 ``cttr`` 필드를 사용할 것. ``WSManager.price_cache`` 에 이미 들어 있음. 실사용 가이드: - 유니버스 시드만 필요하면 ``get_execution_strength_rank()`` 를 직접 호출 (정렬 순서만 쓰고 값 파싱은 생략). - 체결강도 값 기반 필터(≥ 120 등)는 전략 코드에서 WS tick 으로 해결. """ rows = self.get_execution_strength_rank(market=market, limit=limit) out: Dict[str, float] = {} for it in rows or []: code = ( it.get("mksc_shrn_iscd") or it.get("stk_cd") or it.get("code") or "" ).strip() if not code or len(code) != 6: continue raw = ( it.get("cntr_str") or it.get("exec_str") or it.get("strg_rt") or "" ) try: out[code] = float(str(raw).replace(",", "").strip()) if raw else 0.0 except (ValueError, TypeError): out[code] = 0.0 return out # ------------------------------------------------------------------ # 계좌/잔고 # ------------------------------------------------------------------ def get_account_balance(self) -> Optional[dict]: """계좌 잔고 [국내주식-006] (inquire-balance). ※ 연속조회(페이징): 한 번의 호출은 실전 50건 / 모의 20건까지만 내려온다. 보유 종목이 한도를 넘으면 응답 헤더 tr_cont 가 'M'(또는 'F') 로 오며, 이때 직전 응답의 ctx_area_fk100/nk100 을 다음 요청에 실어 반복 조회한다. output1(보유 종목 배열)을 누적 병합해 반환하므로 51번째 이후 종목도 보존된다. (이 맵이 모든 매도의 보유수량 검증 기준 → 누락 시 매도 차단 위험이라 페이징 필수) """ tr_id = "VTTC8434R" if self.mock else "TTTC8434R" path = "/uapi/domestic-stock/v1/trading/inquire-balance" # 무한루프 방지 — 1페이지=실전50/모의20종목, 기본 20p(최대 약 1000종목)면 충분 max_pages = get_env_int("BALANCE_MAX_PAGES", 20) base_params = { "CANO": self.account_no, "ACNT_PRDT_CD": self.account_code, "AFHR_FLPR_YN": "N", "OFL_YN": "N", "INQR_DVSN": "01", "UNPR_DVSN": "01", "FUND_STTL_ICLD_YN": "N", "FNCG_AMT_AUTO_RDPT_YN": "N", "PRCS_DVSN": "00", "CTX_AREA_FK100": "", "CTX_AREA_NK100": "", } merged: Optional[dict] = None # 첫 페이지 원본(output2 요약 등 보존) all_output1: List[dict] = [] # 보유 종목 누적 병합 fk100, nk100, tr_cont = "", "", "" try: for page in range(1, max_pages + 1): params = dict(base_params) params["CTX_AREA_FK100"] = fk100 params["CTX_AREA_NK100"] = nk100 r = self._get(path, tr_id, params, tr_cont=tr_cont) if r.status_code != 200: if page == 1: return None # 첫 페이지 실패 → None (유령잔고 오판 방지) logger.warning( "잔고 연속조회 %d페이지 HTTP %s → 지금까지 %d종목 부분 반환", page, r.status_code, len(all_output1), ) break j = r.json() if j.get("rt_cd") != "0": if page == 1: return None logger.warning( "잔고 연속조회 %d페이지 rt_cd=%s msg=%s → 부분 반환", page, j.get("rt_cd"), j.get("msg1"), ) break if merged is None: merged = j page_out1 = j.get("output1") or [] if isinstance(page_out1, dict): page_out1 = [page_out1] all_output1.extend(page_out1) # 다음 페이지 판단: 응답 헤더 tr_cont (F/M = 다음 있음, D/E/공백 = 끝) next_cont = "" try: next_cont = str(r.headers.get("tr_cont") or "").strip() except Exception: next_cont = "" fk100 = str(j.get("ctx_area_fk100") or "").strip() nk100 = str(j.get("ctx_area_nk100") or "").strip() if next_cont in ("F", "M") and (fk100 or nk100): tr_cont = "N" # 다음 페이지 요청 continue break # 마지막 페이지 else: # for-else: max_pages 소진했는데도 루프가 break 안됨 = 더 남았을 수 있음 logger.warning( "잔고 연속조회 최대 %d페이지 도달 → %d종목까지만 반환(추가 종목 누락 가능)", max_pages, len(all_output1), ) if merged is None: return None merged["output1"] = all_output1 # 누적 병합본으로 교체 (단일 페이지면 동일) self._last_inquire_balance = merged return merged except Exception as e: logger.error("계좌 잔고 조회 실패: %s", e) return None def get_broker_holdings_map(self) -> Optional[Dict[str, Dict]]: """ 잔고 API output1 → {code: {qty=보유, hldg_qty, ord_psbl_qty=매도가능, ...}}. ** 모든 매도 주문 직전 이 맵으로 실제 보유 수량을 검증해야 한다. ** 매도주문 중이면 매도가능=0 이 정상 — 보유와 섞어 0으로 만들지 않는다. API 실패 시 None (빈 dict 와 구분 — 유령잔고 오판 방지). """ balance = self.get_account_balance() if not balance: return None output1 = balance.get("output1") or [] if isinstance(output1, dict): output1 = [output1] result: Dict[str, Dict] = {} for it in output1: row = holdings_row_from_balance_item(it) if not row: continue code = str(it.get("pdno") or it.get("PDNO") or "").strip() result[code] = row return result def get_cancelable_sell_map(self) -> Optional[Dict[str, Dict]]: """ 주식정정취소가능주문조회 [국내주식-004] inquire-psbl-rvsecncl. ※ 공식 스펙: **실전 전용** (모의는 '없는 서비스 코드'). 모의·미지원이면 당일 주문체결(inquire-daily-ccld) 미체결 매도로 대체. 반환: {code: {psbl_qty, odno, ord_qty, tot_ccld_qty, name}} API 실패 시 None. 미체결 없으면 빈 dict. """ if self.mock: logger.info( "정정취소가능조회는 모의 미지원 → 당일주문체결 미체결 매도로 대체" ) return self._open_sell_map_from_daily_ccld() default_tr = str( get_env_from_db("PSBL_RVSECNCL_TR_ID", "TTTC0084R") or "TTTC0084R" ).strip() or "TTTC0084R" inqr1 = str( get_env_from_db("PSBL_RVSECNCL_INQR_DVSN_1", "0") or "0" ).strip() or "0" inqr2 = str( get_env_from_db("PSBL_RVSECNCL_INQR_DVSN_2", "1") or "1" ).strip() or "1" max_pages = max(1, int(get_env_int("PSBL_RVSECNCL_MAX_PAGES", 5))) path = "/uapi/domestic-stock/v1/trading/inquire-psbl-rvsecncl" merged: List[dict] = [] fk100, nk100, tr_cont = "", "", "" last_ok = False try: import time as _t for page in range(1, max_pages + 1): r = self._get( path, default_tr, { "CANO": self.account_no, "ACNT_PRDT_CD": self.account_code, "INQR_DVSN_1": inqr1, "INQR_DVSN_2": inqr2, "CTX_AREA_FK100": fk100, "CTX_AREA_NK100": nk100, }, tr_cont=tr_cont, ) if r is None or r.status_code != 200: if page == 1: logger.warning( "정정취소가능조회 HTTP 실패 → 당일체결 미체결로 대체" ) return self._open_sell_map_from_daily_ccld() logger.warning( "정정취소가능 연속조회 %d페이지 HTTP %s → 부분 사용", page, getattr(r, "status_code", "?"), ) break j = r.json() if r is not None else {} if j.get("rt_cd") != "0": msg1 = str(j.get("msg1") or "") if page == 1: logger.warning( "정정취소가능조회 실패 rt_cd=%s msg=%s → 당일체결 미체결로 대체", j.get("rt_cd"), msg1, ) return self._open_sell_map_from_daily_ccld() logger.warning( "정정취소가능 연속조회 %d페이지 rt_cd=%s → 부분 사용", page, j.get("rt_cd"), ) break last_ok = True chunk = j.get("output") or j.get("output1") or [] if isinstance(chunk, dict): chunk = [chunk] if isinstance(chunk, list): merged.extend(chunk) hdr_cont = "" try: hdr_cont = str(r.headers.get("tr_cont") or "").strip().upper() except Exception: hdr_cont = "" body_fk = str(j.get("ctx_area_fk100") or "").strip() body_nk = str(j.get("ctx_area_nk100") or "").strip() if hdr_cont not in ("M", "F") or not body_nk: break fk100 = body_fk nk100 = body_nk tr_cont = "N" _t.sleep(max(0.05, float(get_env_float("PSBL_RVSECNCL_PAGE_GAP_SEC", 0.08)))) if not last_ok: return self._open_sell_map_from_daily_ccld() except Exception as e: logger.warning("정정취소가능조회 예외: %s → 당일체결 미체결로 대체", e) return self._open_sell_map_from_daily_ccld() result: Dict[str, Dict] = {} for it in merged: if not isinstance(it, dict): continue code = str(it.get("pdno") or it.get("PDNO") or "").strip() if not code: continue psbl = parse_kis_int_qty(it.get("psbl_qty") or it.get("PSBL_QTY")) if psbl <= 0: continue odno = str(it.get("odno") or it.get("ODNO") or "").strip() name = str(it.get("prdt_name") or it.get("PRDT_NAME") or code).strip() ord_qty = parse_kis_int_qty(it.get("ord_qty") or it.get("ORD_QTY")) ccld = parse_kis_int_qty( it.get("tot_ccld_qty") or it.get("TOT_CCLD_QTY") ) prev = result.get(code) if prev: prev["psbl_qty"] = int(prev.get("psbl_qty") or 0) + psbl prev["ord_qty"] = int(prev.get("ord_qty") or 0) + ord_qty prev["tot_ccld_qty"] = int(prev.get("tot_ccld_qty") or 0) + ccld if not prev.get("odno") and odno: prev["odno"] = odno else: result[code] = { "psbl_qty": psbl, "odno": odno, "ord_qty": ord_qty, "tot_ccld_qty": ccld, "name": name, } return result def _open_sell_map_from_daily_ccld(self) -> Optional[Dict[str, Dict]]: """모의·정정취소가능 미지원 시: 당일 주문체결에서 미체결 매도 잔량. cancelable 전용 페이지 상한: CANCELABLE_CCLD_MAX_PAGES (기본 3). pending fill 일괄 조회는 get_order_history_today() 기본 DAILY_CCLD_MAX_PAGES 유지. """ _ccld_max = max(1, int(get_env_int("CANCELABLE_CCLD_MAX_PAGES", 3))) j = self.get_order_history_today(odno="", max_pages=_ccld_max) if j is None: return None out1 = j.get("output1") or [] if isinstance(out1, dict): out1 = [out1] result: Dict[str, Dict] = {} for row in out1: if not isinstance(row, dict): continue side = str( row.get("sll_buy_dvsn_cd") or row.get("SLL_BUY_DVSN_CD") or "" ).strip() # 01=매도, 02=매수 (한자리 1 도 허용) if side not in ("01", "1"): continue cncl = str( row.get("cncl_yn") or row.get("CNCL_YN") or "" ).strip().upper() if cncl in ("Y", "1"): continue code = str(row.get("pdno") or row.get("PDNO") or "").strip() if not code: continue ord_qty = parse_kis_int_qty(row.get("ord_qty") or row.get("ORD_QTY")) ccld = parse_kis_int_qty( row.get("tot_ccld_qty") or row.get("TOT_CCLD_QTY") ) remain = parse_kis_int_qty(row.get("rmn_qty") or row.get("RMN_QTY")) if remain <= 0: remain = max(0, ord_qty - ccld) if remain <= 0: continue odno = str(row.get("odno") or row.get("ODNO") or "").strip() name = str(row.get("prdt_name") or row.get("PRDT_NAME") or code).strip() prev = result.get(code) if prev: prev["psbl_qty"] = int(prev.get("psbl_qty") or 0) + remain prev["ord_qty"] = int(prev.get("ord_qty") or 0) + ord_qty prev["tot_ccld_qty"] = int(prev.get("tot_ccld_qty") or 0) + ccld if not prev.get("odno") and odno: prev["odno"] = odno else: result[code] = { "psbl_qty": remain, "odno": odno, "ord_qty": ord_qty, "tot_ccld_qty": ccld, "name": name, "source": "daily_ccld", } return result # ------------------------------------------------------------------ # 주문 # ------------------------------------------------------------------ def _order( self, *, code: str, qty: int, price: int, order_type: str, side: str, ) -> Optional[str]: """ 주문 공통 호출. 성공 시 ODNO(str) 반환, 실패 시 None. side: 'BUY' | 'SELL' """ side = side.upper() if side == "BUY": tr_id = "VTTC0802U" if self.mock else "TTTC0802U" else: tr_id = "VTTC0801U" if self.mock else "TTTC0801U" path = "/uapi/domestic-stock/v1/trading/order-cash" body = { "CANO": self.account_no, "ACNT_PRDT_CD": self.account_code, "PDNO": code, "ORD_DVSN": order_type, "ORD_QTY": str(qty), "ORD_UNPR": str(price), } try: from kis_trader.utils.api_reject_log import record_api_reject r = self._post(path, tr_id, body) if r.status_code != 200: body_snip = (r.text or "")[:200] http_cd = f"HTTP_{int(r.status_code)}" if side == "BUY": self._last_order_msg_cd = http_cd self._last_order_msg1 = body_snip else: self._last_sell_msg_cd = http_cd self._last_sell_msg1 = body_snip logger.error( "주문 HTTP 에러 side=%s code=%s status=%s", side, code, r.status_code, ) record_api_reject( kind="domestic_order_http", side=side, code=str(code or ""), msg_cd=http_cd, msg1=body_snip, http=r.status_code, path=str(path), extra={"mock": bool(self.mock)}, ) return None j = r.json() if j.get("rt_cd") == "0": if side == "BUY": self._last_order_msg_cd = None self._last_order_msg1 = None else: self._last_sell_msg_cd = None self._last_sell_msg1 = None ord_no = str((j.get("output") or {}).get("ODNO", "") or "").strip() return ord_no or None # 실패: 원인 저장 (매매불가/영업일 아님 등 분기용) if side == "BUY": self._last_order_msg_cd = j.get("msg_cd", "") self._last_order_msg1 = str(j.get("msg1", "") or "") logger.error( "[매수주문실패] code=%s rt_cd=%s msg_cd=%s msg1=%s", code, j.get("rt_cd"), self._last_order_msg_cd, self._last_order_msg1, ) else: self._last_sell_msg_cd = j.get("msg_cd", "") self._last_sell_msg1 = str(j.get("msg1", "") or "") logger.error( "[매도주문실패] code=%s rt_cd=%s msg_cd=%s msg1=%s", code, j.get("rt_cd"), self._last_sell_msg_cd, self._last_sell_msg1, ) record_api_reject( kind="domestic_order_reject", side=side, code=str(code or ""), msg_cd=str( (self._last_order_msg_cd if side == "BUY" else self._last_sell_msg_cd) or "" ), msg1=str( (self._last_order_msg1 if side == "BUY" else self._last_sell_msg1) or "" ), rt_cd=str(j.get("rt_cd") or ""), http=200, path=str(path), extra={"mock": bool(self.mock)}, ) return None except Exception as e: if side == "BUY": self._last_order_msg_cd = "EXC" self._last_order_msg1 = str(e)[:200] else: self._last_sell_msg_cd = "EXC" self._last_sell_msg1 = str(e)[:200] logger.error("주문 예외 side=%s code=%s: %s", side, code, e) try: from kis_trader.utils.api_reject_log import record_api_reject record_api_reject( kind="domestic_order_exception", side=side, code=str(code or ""), msg_cd="EXC", msg1=str(e)[:300], path=str(path), extra={"mock": bool(self.mock)}, ) except Exception: pass return None def buy_market_order(self, code: str, qty: int) -> Optional[str]: """시장가 매수. 실전은 USE_MARKET_IOC 설정 시 IOC(13), 아니면 일반 시장가(01).""" if self.mock: order_type = "01" else: order_type = "13" if get_env_bool("USE_MARKET_IOC", True) else "01" return self._order( code=code, qty=qty, price=0, order_type=order_type, side="BUY" ) def buy_limit_order(self, code: str, qty: int, price: int) -> Optional[str]: """지정가 매수 (ORD_DVSN=00). 돌파/꼬리잡기 전략에서 사용.""" return self._order( code=code, qty=qty, price=int(price), order_type="00", side="BUY" ) def cancel_order_detail( self, org_odno: str, *, org_branch: str = "", qty: int = 0, order_dvsn: str = "00", ) -> Dict[str, object]: """미체결 주문 전량 취소 (RVSE_CNCL_DVSN_CD=02). rt_cd/msg_cd/msg1 포함.""" odno = str(org_odno or "").strip() empty = { "ok": False, "rt_cd": "", "msg_cd": "", "msg1": "", "http_status": 0, } if not odno: return empty tr_id = "VTTC0803U" if self.mock else "TTTC0803U" path = "/uapi/domestic-stock/v1/trading/order-rvsecncl" body = { "CANO": self.account_no, "ACNT_PRDT_CD": self.account_code, "KRX_FWDG_ORD_ORGNO": str(org_branch or ""), "ORGN_ODNO": odno, "ORD_DVSN": order_dvsn, "RVSE_CNCL_DVSN_CD": "02", "ORD_QTY": str(int(qty)), "ORD_UNPR": "0", "QTY_ALL_ORD_YN": "Y", } try: r = self._post(path, tr_id, body) http_st = int(getattr(r, "status_code", 0) or 0) if r.status_code != 200: log_kis_api_response( "cancel_order", None, http_status=http_st, tr_id=tr_id, extra=f"odno={odno}", ) self._last_cancel_rt_cd = f"HTTP_{http_st}" self._last_cancel_msg_cd = "" self._last_cancel_msg1 = (r.text or "")[:200] return { "ok": False, "rt_cd": self._last_cancel_rt_cd, "msg_cd": "", "msg1": self._last_cancel_msg1, "http_status": http_st, } j = r.json() rt_cd = str(j.get("rt_cd") or "") msg_cd = str(j.get("msg_cd") or "") msg1 = str(j.get("msg1") or "") self._last_cancel_rt_cd = rt_cd self._last_cancel_msg_cd = msg_cd self._last_cancel_msg1 = msg1 ok = rt_cd == "0" if ok: log_kis_api_response( "cancel_order", j, http_status=http_st, tr_id=tr_id, extra=f"odno={odno} ok", level="info", ) else: log_kis_api_response( "cancel_order", j, http_status=http_st, tr_id=tr_id, extra=f"odno={odno}", ) return { "ok": ok, "rt_cd": rt_cd, "msg_cd": msg_cd, "msg1": msg1, "http_status": http_st, } except Exception as e: logger.error("주문취소 예외 odno=%s: %s", odno, e) self._last_cancel_rt_cd = "EXC" self._last_cancel_msg_cd = "EXC" self._last_cancel_msg1 = str(e)[:200] return { "ok": False, "rt_cd": "EXC", "msg_cd": "EXC", "msg1": self._last_cancel_msg1, "http_status": 0, } def cancel_order( self, org_odno: str, *, org_branch: str = "", qty: int = 0, order_dvsn: str = "00", ) -> bool: """미체결 주문 전량 취소 (RVSE_CNCL_DVSN_CD=02).""" return bool( self.cancel_order_detail( org_odno, org_branch=org_branch, qty=qty, order_dvsn=order_dvsn, ).get("ok") ) def sell_limit_order(self, code: str, qty: int, price: int) -> Optional[str]: """지정가 매도 (ORD_DVSN=00). 익절 시 매수 1호가 등.""" return self._order( code=code, qty=qty, price=int(price), order_type="00", side="SELL" ) def uses_market_sell_ioc(self) -> bool: """ 실전 시장가 매도 IOC(ORD_DVSN=13) 사용 여부. 모의는 IOC/FOK 미지원 → 항상 False (주문은 01). """ if self.mock: return False return bool(get_env_bool("USE_MARKET_IOC_SELL", True)) def sell_market_order(self, code: str, qty: int) -> Optional[str]: """ 시장가 매도. - 모의: 항상 01 (일반 시장가) - 실전: USE_MARKET_IOC_SELL=true 이면 13(IOC), false 이면 01 """ order_type = "13" if self.uses_market_sell_ioc() else "01" return self._order( code=code, qty=qty, price=0, order_type=order_type, side="SELL" ) # ------------------------------------------------------------------ # 해외주식 시세·주문 (미국 NASD/NYSE/AMEX) # ※ 매수/매도는 body 가 아니라 헤더 tr_id 로만 구분 (국내 SLL_BUY 와 다름) # ------------------------------------------------------------------ @staticmethod def _ovrs_excg_cd(exchange: Optional[str] = None) -> str: """주문용 거래소 코드 (NASD/NYSE/AMEX). 시세 EXCD(NAS) 와 다름.""" raw = ( exchange or get_env_from_db("KIS_OVRS_DEFAULT_EXCG", "NASD") or "NASD" ).strip().upper() aliases = { "NAS": "NASD", "NASDAQ": "NASD", "NASD": "NASD", "NYS": "NYSE", "NYSE": "NYSE", "AMS": "AMEX", "AMEX": "AMEX", } return aliases.get(raw, raw or "NASD") @staticmethod def _ovrs_price_excd(exchange: Optional[str] = None) -> str: """시세용 EXCD (NAS/NYS/AMS).""" order_cd = KISClient._ovrs_excg_cd(exchange) return {"NASD": "NAS", "NYSE": "NYS", "AMEX": "AMS"}.get(order_cd, "NAS") def inquire_overseas_price( self, symbol: str, *, exchange: Optional[str] = None, ) -> Optional[dict]: """해외주식 현재가 [해외주식-029] HHDFS00000300.""" symb = str(symbol or "").strip().upper() if not symb: return None excd = self._ovrs_price_excd(exchange) try: r = self._get( "/uapi/overseas-price/v1/quotations/price", "HHDFS00000300", {"AUTH": "", "EXCD": excd, "SYMB": symb}, ) if r.status_code != 200: logger.warning("해외시세 HTTP %s %s", r.status_code, symb) return None j = r.json() if j.get("rt_cd") != "0": logger.warning( "해외시세 실패 %s rt_cd=%s msg=%s", symb, j.get("rt_cd"), j.get("msg1"), ) return None return j.get("output") or {} except Exception as e: logger.warning("해외시세 예외 %s: %s", symb, e) return None def _overseas_order_tr_id(self, side: str, exchange: str) -> str: """미국 매수 TTTT1002U / 매도 TTTT1006U. 모의는 앞 T→V.""" side_u = (side or "").upper() excg = self._ovrs_excg_cd(exchange) if excg not in ("NASD", "NYSE", "AMEX"): raise ValueError(f"unsupported overseas exchange for US order: {excg}") if side_u == "BUY": real_tr = get_env_from_db("KIS_OVRS_US_BUY_TR_ID", "TTTT1002U") or "TTTT1002U" elif side_u == "SELL": real_tr = get_env_from_db("KIS_OVRS_US_SELL_TR_ID", "TTTT1006U") or "TTTT1006U" else: raise ValueError(f"side must be BUY/SELL, got {side}") real_tr = str(real_tr).strip().upper() if self.mock: # 공식 샘플: demo 시 'V' + tr_id[1:] (TTTT… → VTTT…) return "V" + real_tr[1:] return real_tr def overseas_order( self, *, symbol: str, qty: int, price: float, side: str, exchange: Optional[str] = None, ord_dvsn: Optional[str] = None, ) -> Optional[str]: """ 해외주식 주문 [v1_해외주식-001]. 성공 시 ODNO, 실패 시 None. 모의(VTTT*): ORD_DVSN=00 지정가만 가능. price>0 필수. """ symb = str(symbol or "").strip().upper() qty_i = int(qty or 0) if not symb or qty_i <= 0: return None excg = self._ovrs_excg_cd(exchange) side_u = (side or "").upper() if ord_dvsn is None or str(ord_dvsn).strip() == "": ord_dvsn = get_env_from_db("KIS_OVRS_ORD_DVSN", "00") or "00" ord_dvsn = str(ord_dvsn).strip() px = float(price or 0) if self.mock and (ord_dvsn != "00" or px <= 0): logger.error( "해외모의주문은 지정가(00)+가격>0 필수 side=%s %s", side_u, symb, ) return None px_decimals = int(get_env_int("KIS_OVRS_PRICE_DECIMALS", 2)) px_str = f"{px:.{px_decimals}f}" try: tr_id = self._overseas_order_tr_id(side_u, excg) except ValueError as e: logger.error("해외주문 tr_id: %s", e) return None path = get_env_from_db( "KIS_OVRS_ORDER_PATH", "/uapi/overseas-stock/v1/trading/order", ) or "/uapi/overseas-stock/v1/trading/order" # 매도만 SLL_TYPE=00 (공식 샘플). 매수는 공란. sll_type = "00" if side_u == "SELL" else "" body = { "CANO": self.account_no, "ACNT_PRDT_CD": self.account_code, "OVRS_EXCG_CD": excg, "PDNO": symb, "ORD_QTY": str(qty_i), "OVRS_ORD_UNPR": px_str, "CTAC_TLNO": "", "MGCO_APTM_ODNO": "", "SLL_TYPE": sll_type, "ORD_SVR_DVSN_CD": get_env_from_db("KIS_OVRS_ORD_SVR_DVSN_CD", "0") or "0", "ORD_DVSN": ord_dvsn, } try: from kis_trader.utils.api_reject_log import record_api_reject r = self._post(path, tr_id, body) if r.status_code != 200: body_snip = (r.text or "")[:200] http_cd = f"HTTP_{int(r.status_code)}" if side_u == "BUY": self._last_order_msg_cd = http_cd self._last_order_msg1 = body_snip else: self._last_sell_msg_cd = http_cd self._last_sell_msg1 = body_snip logger.error( "해외주문 HTTP side=%s %s status=%s body=%s", side_u, symb, r.status_code, body_snip, ) record_api_reject( kind="overseas_order_http", side=side_u, code=symb, msg_cd=http_cd, msg1=body_snip, http=r.status_code, path=str(path), extra={"tr_id": tr_id, "mock": bool(self.mock)}, ) return None j = r.json() if j.get("rt_cd") == "0": if side_u == "BUY": self._last_order_msg_cd = None self._last_order_msg1 = None else: self._last_sell_msg_cd = None self._last_sell_msg1 = None out = j.get("output") or {} ord_no = str(out.get("ODNO") or out.get("odno") or "").strip() logger.info( "✅ 해외주문 접수 side=%s %s qty=%s @%s excg=%s tr=%s odno=%s", side_u, symb, qty_i, px_str, excg, tr_id, ord_no, ) return ord_no or None msg_cd = j.get("msg_cd", "") msg1 = str(j.get("msg1", "") or "") if side_u == "BUY": self._last_order_msg_cd = msg_cd self._last_order_msg1 = msg1 else: self._last_sell_msg_cd = msg_cd self._last_sell_msg1 = msg1 logger.error( "[해외주문실패] side=%s %s rt_cd=%s msg_cd=%s msg1=%s", side_u, symb, j.get("rt_cd"), msg_cd, msg1, ) record_api_reject( kind="overseas_order_reject", side=side_u, code=symb, msg_cd=str(msg_cd or ""), msg1=msg1, rt_cd=str(j.get("rt_cd") or ""), http=200, path=str(path), extra={"tr_id": tr_id, "mock": bool(self.mock)}, ) return None except Exception as e: if side_u == "BUY": self._last_order_msg_cd = "EXC" self._last_order_msg1 = str(e)[:200] else: self._last_sell_msg_cd = "EXC" self._last_sell_msg1 = str(e)[:200] logger.error("해외주문 예외 side=%s %s: %s", side_u, symb, e) try: from kis_trader.utils.api_reject_log import record_api_reject record_api_reject( kind="overseas_order_exception", side=side_u, code=symb, msg_cd="EXC", msg1=str(e)[:300], path=str(path), extra={"mock": bool(self.mock)}, ) except Exception: pass return None def inquire_overseas_psamount( self, symbol: str, price: float, *, exchange: Optional[str] = None, ) -> Dict: """ 해외주식 매수가능금액조회 [v1_해외주식-014] (TTTS3007R / VTTS3007R). Returns: dict: ord_psbl_qty, max_ord_psbl_qty, ord_psbl_frcr_amt, exrt, ... 실패 시 빈 dict. """ symb = str(symbol or "").strip().upper() px = float(price or 0) if not symb or px <= 0: return {} excg = self._ovrs_excg_cd(exchange) px_decimals = int(get_env_int("KIS_OVRS_PRICE_DECIMALS", 2)) px_str = f"{px:.{px_decimals}f}" path = get_env_from_db( "KIS_OVRS_PSAMOUNT_PATH", "/uapi/overseas-stock/v1/trading/inquire-psamount", ) or "/uapi/overseas-stock/v1/trading/inquire-psamount" tr_id = "VTTS3007R" if self.mock else "TTTS3007R" params = { "CANO": self.account_no, "ACNT_PRDT_CD": self.account_code, "OVRS_EXCG_CD": excg, "OVRS_ORD_UNPR": px_str, "ITEM_CD": symb, } try: r = self._get(path, tr_id, params) if r.status_code != 200: logger.warning( "해외매수가능 HTTP %s %s status=%s", symb, excg, r.status_code, ) return {} j = r.json() if r is not None else {} if j.get("rt_cd") != "0": logger.warning( "해외매수가능 실패 %s rt_cd=%s msg_cd=%s msg1=%s", symb, j.get("rt_cd"), j.get("msg_cd"), j.get("msg1"), ) return {} out = j.get("output") or j.get("output1") or {} if isinstance(out, list): out = out[0] if out else {} if not isinstance(out, dict): return {} # 수량 필드 정규화 (문자열→int) def _qi(key: str) -> int: try: return int(float(str(out.get(key) or "0").replace(",", "") or 0)) except (TypeError, ValueError): return 0 out = dict(out) out["ord_psbl_qty"] = _qi("ord_psbl_qty") out["max_ord_psbl_qty"] = _qi("max_ord_psbl_qty") out["ovrs_max_ord_psbl_qty"] = _qi("ovrs_max_ord_psbl_qty") return out except Exception as e: logger.warning("해외매수가능 예외 %s: %s", symb, e) return {} def buy_overseas_limit( self, symbol: str, qty: int, price: float, *, exchange: Optional[str] = None, ) -> Optional[str]: """해외 지정가 매수.""" return self.overseas_order( symbol=symbol, qty=qty, price=price, side="BUY", exchange=exchange, ) def sell_overseas_limit( self, symbol: str, qty: int, price: float, *, exchange: Optional[str] = None, ) -> Optional[str]: """해외 지정가 매도.""" return self.overseas_order( symbol=symbol, qty=qty, price=price, side="SELL", exchange=exchange, ) # ------------------------------------------------------------------ # 조건검색 (REST — 웹소켓은 공식 지원 안 함. 30초 폴링이 정석) # ------------------------------------------------------------------ def get_condition_list(self, user_id: str) -> List[Dict]: """ HTS/MTS 에 서버 저장된 조건식 목록 조회. [국내주식] 시세분석 - 종목조건검색 목록조회 (psearch-title) Returns: [{"seq":"0","condition_name":"우상향돌파",...}, ...] """ try: r = self._get( "/uapi/domestic-stock/v1/quotations/psearch-title", "HHKST03900300", {"user_id": user_id}, ) if r.status_code != 200: return [] j = r.json() if j.get("rt_cd") != "0": return [] out = j.get("output2") or [] if isinstance(out, dict): out = [out] return out except Exception as e: logger.debug("조건식 목록 조회 실패: %s", e) return [] def get_condition_result(self, user_id: str, seq: str) -> List[Dict]: """ 조건식에 걸려 있는 종목 목록 조회 (psearch-result). Returns: [{"code":"005930","name":"삼성전자",...}, ...] """ try: r = self._get( "/uapi/domestic-stock/v1/quotations/psearch-result", "HHKST03900400", {"user_id": user_id, "seq": str(seq)}, ) if r.status_code != 200: return [] j = r.json() if j.get("rt_cd") != "0": return [] out = j.get("output2") or [] if isinstance(out, dict): out = [out] parsed: List[Dict] = [] for it in out: code = ( it.get("code") or it.get("stck_shrn_iscd") or it.get("mksc_shrn_iscd") or "" ).strip() if not code: continue name = ( it.get("name") or it.get("hts_kor_isnm") or it.get("stck_prpr") or code ) parsed.append({"code": code, "name": str(name).strip() or code}) return parsed except Exception as e: logger.debug("조건검색 결과 조회 실패 (seq=%s): %s", seq, e) return [] def get_order_history_today( self, odno: str = "", max_pages: Optional[int] = None ) -> Optional[dict]: """당일 주문 내역 조회 [국내주식-005 inquire-daily-ccld]. TR_ID: 공식 샘플 기준 3개월이내 = 실전 ``TTTC0081R`` / 모의 ``VTTC0081R``. (레거시 ``*8001R`` 은 모의에서 '내역 없음'/빈맵이 나와 체결 재확인이 깨짐) ``odno`` 지정 시 해당 주문번호만 조회. 미지정 시 연속조회(모의 15건/페이지)로 output1 을 병합해 반환한다. ``max_pages`` 미지정 시 DAILY_CCLD_MAX_PAGES(기본 20). """ # 공식 OpenAPI 샘플(inquire_daily_ccld) — env 로만 레거시 TR 오버라이드 default_tr = "VTTC0081R" if self.mock else "TTTC0081R" tr_id = str( get_env_from_db("DAILY_CCLD_TR_ID", default_tr) or default_tr ).strip() or default_tr today = dt.now().strftime("%Y%m%d") # 모의 1페이지≈15건 — 장중 체결 누락 방지. 과도 연속조회 방지 상한. _default_max = max(1, int(get_env_int("DAILY_CCLD_MAX_PAGES", 20))) page_limit = max(1, int(max_pages)) if max_pages is not None else _default_max try: merged_out1: list = [] last_j: Optional[dict] = None fk100 = "" nk100 = "" tr_cont = "" for _page in range(page_limit): r = self._get( "/uapi/domestic-stock/v1/trading/inquire-daily-ccld", tr_id, { "CANO": self.account_no, "ACNT_PRDT_CD": self.account_code, "INQR_STRT_DT": today, "INQR_END_DT": today, "SLL_BUY_DVSN_CD": "00", "INQR_DVSN": "00", "PDNO": "", "CCLD_DVSN": "00", "ORD_GNO_BRNO": "", "ODNO": str(odno or ""), "INQR_DVSN_3": "00", "INQR_DVSN_1": "", "CTX_AREA_FK100": fk100, "CTX_AREA_NK100": nk100, "EXCG_ID_DVSN_CD": str( get_env_from_db("DAILY_CCLD_EXCG_ID", "KRX") or "KRX" ), }, tr_cont=tr_cont, ) if r is None or r.status_code != 200: if merged_out1 and last_j is not None: break log_kis_api_response( "daily_ccld", None, http_status=getattr(r, "status_code", None), tr_id=tr_id, extra=f"odno={odno or '-'} page={_page + 1}", ) return None j = r.json() if j.get("rt_cd") != "0": if merged_out1 and last_j is not None: break log_kis_api_response( "daily_ccld", j, http_status=r.status_code, tr_id=tr_id, extra=f"odno={odno or '-'} page={_page + 1}", ) return None last_j = j chunk = j.get("output1") or [] if isinstance(chunk, dict): chunk = [chunk] if isinstance(chunk, list): merged_out1.extend(chunk) # ODNO 단건이면 1페이지로 충분 if odno: break hdr_cont = "" try: hdr_cont = str(r.headers.get("tr_cont") or "").strip().upper() except Exception: hdr_cont = "" body_fk = str(j.get("ctx_area_fk100") or "").strip() body_nk = str(j.get("ctx_area_nk100") or "").strip() if hdr_cont not in ("M", "F") or not body_nk: break fk100 = body_fk nk100 = body_nk tr_cont = "N" # 연속조회 간 짧은 간격 (유량 존중) import time as _t _t.sleep(max(0.05, float(get_env_float("DAILY_CCLD_PAGE_GAP_SEC", 0.08)))) if last_j is None: return None out = dict(last_j) out["output1"] = merged_out1 return out except Exception as e: logger.debug("주문 내역 조회 실패: %s", e) return None def inquire_psbl_order( self, code: str = "005930", *, price: int = 0, ord_dvsn: str = "01", ) -> Optional[Dict]: """매수가능조회 [국내주식-007] inquire-psbl-order (TTTC8908R / VTTC8908R). 주식잔고(inquire-balance) output2 에는 주문가능현금 필드가 없다. 주문가능현금·미수없는매수금액은 이 API 의 ``ord_psbl_cash`` / ``nrcvb_buy_amt`` 를 쓴다 (공식 샘플 주석과 동일). """ pdno = str(code or "005930").strip() tr_id = "VTTC8908R" if self.mock else "TTTC8908R" try: r = self._get( "/uapi/domestic-stock/v1/trading/inquire-psbl-order", tr_id, { "CANO": self.account_no, "ACNT_PRDT_CD": self.account_code, "PDNO": pdno, "ORD_UNPR": str(int(price or 0)), "ORD_DVSN": str(ord_dvsn or "01"), "CMA_EVLU_AMT_ICLD_YN": str( get_env_from_db("PSBL_ORDER_CMA_ICLD", "N") or "N" ), "OVRS_ICLD_YN": str( get_env_from_db("PSBL_ORDER_OVRS_ICLD", "N") or "N" ), }, ) if r is None or r.status_code != 200: return None j = r.json() if j.get("rt_cd") != "0": return None out = j.get("output") or j.get("output1") or {} if isinstance(out, list) and out: out = out[0] return out if isinstance(out, dict) else None except Exception as e: logger.debug("매수가능조회 실패: %s", e) return None @staticmethod def _execution_map_from_history(j: Optional[dict]) -> Dict[str, Dict]: """inquire-daily-ccld 응답 → {odno: {filled_qty, avg_price, code}}.""" out: Dict[str, Dict] = {} if not j: return out out1 = j.get("output1") or [] if isinstance(out1, dict): out1 = [out1] for row in out1: odno = str(row.get("odno") or row.get("ODNO") or "").strip() if not odno: continue filled = row.get("tot_ccld_qty") or row.get("TOT_CCLD_QTY") or 0 avg = ( row.get("avg_prvs") or row.get("AVG_PRVS") or row.get("ord_unpr") or row.get("ORD_UNPR") or 0 ) try: q = int(float(str(filled).replace(",", ""))) p = float(str(avg).replace(",", "")) except Exception: continue if q <= 0 or p <= 0: continue pdno = str(row.get("pdno") or row.get("PDNO") or "").strip() out[odno] = {"filled_qty": q, "avg_price": p, "code": pdno} return out def get_today_execution_map(self, wait_sec: float = 0.0) -> Optional[Dict[str, Dict]]: """당일 체결 일괄 조회 — poll_pending_fills N건→1 REST 용. 반환 None: API 실패 시 호출부가 건별 get_execution_by_odno 로 폴백. """ import time as _t _t.sleep(max(0.0, wait_sec)) j = self.get_order_history_today(odno="") if j is None: return None return self._execution_map_from_history(j) def get_execution_by_odno( self, ord_no: str, code: Optional[str] = None, wait_sec: float = 2.0 ) -> Optional[Dict]: """ ODNO 로 당일 체결 조회. 반환: {"filled_qty": int, "avg_price": float} 또는 None 주문유형(01/13)과 무관하게 먼저 ``wait_sec`` 만큼 sleep 한 뒤 REST. 틱매도 콜백에서 호출되면 그 WS 수신 스레드가 그대로 멈춘다. """ import time as _t if not ord_no: return None _t.sleep(max(0.0, wait_sec)) # ODNO 단건 조회 (응답 경량 → 모의서버 500 회피). 필터 무시되면 전체를 받아 아래에서 필터. j = self.get_order_history_today(odno=str(ord_no).strip()) fill_map = self._execution_map_from_history(j) hit = fill_map.get(str(ord_no).strip()) if not hit: return None if code and hit.get("code") and hit["code"] != str(code).strip(): return None return {"filled_qty": hit["filled_qty"], "avg_price": hit["avg_price"]}