""" kis_trader/network/kiwoom_condition_manager.py — 키움 조건검색 기반 동적 유니버스 (WS 실시간) ================================================================================================== 팩트 체크 먼저: * KIS 조건검색(psearch-title/psearch-result)은 **웹소켓 미지원 → REST 폴링만** 가능하다. (기존 ``ConditionSearchManager`` 참고) * 반면 키움 신형 오픈API 는 **웹소켓으로 조건검색 실시간(편입/이탈 push)** 을 지원한다. - CNSRLST : 서버 저장 조건식 목록 (name → seq 해결) - CNSRREQ : 실시간 조건검색 등록 (search_type="1") → 초기 매칭 + 이후 REAL push - REAL : 편입(843="I") / 이탈(843="D"), 종목코드는 9001 - CNSRCLR : 실시간 해제 (재LOGIN/재등록 전 선행 — Bye 후 유령등록·900003 방지) (이 경로는 ``_test_kiwoom_condition_realtime.py`` 에서 실계정으로 검증 완료) 재접속 복구: * 키움 WS ``Bye`` 후에도 계정에 조건검색이 남을 수 있음 → ``KIWOOM_CNSRCLR_BEFORE_REQ`` (기본 true) 로 CNSRREQ 전에 CNSRCLR. * rc=900003 은 seq 당 1회만 CLR→REQ (무한 재시도 금지). 설계 (최소 침습): * ``ConditionSearchManager`` 를 **서브클래싱** 하여 - 결과 반영 로직(``_apply_result``), 순서 계산(``_build_ordered_universe``), 스냅샷 저장(``_save_snapshot``), 조회 API(``get_universe_for``/``get_candidates_for``), EXIT grace, name_map, _configs 정규화 등을 **그대로 재사용**한다. - 데이터 취득만 REST 폴링 → **키움 WS 실시간** 으로 오버라이드. * 스냅샷은 KIS 와 **동일한** ``target_candidates_history`` 에 저장하므로 백테스트/유니버스 타임라인 코드는 변경 없이 그대로 재현 가능하다. * BaseStrategy 는 ``kiwoom_condition_mgr`` 로 주입받아 ``{SID}_UNIVERSE_SOURCE=kiwoom_condition`` 일 때 이 매니저를 소비한다. (KIS ``condition`` 소스와 완전 독립 — 기존 동작 불변) 사용: km = KiwoomConditionSearchManager( app_key=..., app_secret=..., # 반드시 KIWOOM_APP_KEY_REAL (main 이 is_mock=False 고정) configs=[{"strategy_id": "MOMENTUM", "name": "momentum", "seq": "3"}], db=db, ) km.start() codes = km.get_universe_for("MOMENTUM") """ from __future__ import annotations import json import random import re import threading import time from typing import Any, Dict, List, Optional, Set from .condition_manager import ConditionSearchManager from ..utils.env import get_env_bool, get_env_float, get_env_from_db, get_env_int from ..utils.logger import get_logger from ..ws.kiwoom_ws_diag import log_kiwoom_api_msg, log_kiwoom_ws from ..ws.kis_ws import _get_kiwoom_token_cached logger = get_logger("kis_trader.kwcond") # 키움 CNSRREQ rc=900003 — WS Bye 후에도 계정에 조건검색 등록이 남는 경우 _CNSR_ALREADY_REGISTERED_RC = "900003" def _normalize_code(raw) -> str: """키움 종목코드 정규화: 'A005930' → '005930' (KIS 6자리 코드계와 정합).""" c = str(raw or "").strip() if not c: return "" # 키움은 국내주식 코드 앞에 'A' 접두를 붙이는 경우가 있음. if c[0] in ("A", "a") and len(c) >= 7: c = c[1:] return c def _extract_cnsr_seq(data: Dict) -> str: """CNSRREQ/CNSRCLR 응답에서 seq 추출. 실패 응답은 top-level ``seq`` 가 비고 ``return_msg`` 에만 ``...(seq=1)`` 형태로 오는 경우가 있음 (문서·실제 필드 불일치). """ seq = str(data.get("seq") or "").strip() if seq: return seq msg = str(data.get("return_msg") or "") m = re.search(r"seq\s*=\s*(\d+)", msg, flags=re.IGNORECASE) if m: return m.group(1).strip() return "" class KiwoomConditionSearchManager(ConditionSearchManager): """키움 웹소켓 실시간 조건검색 매니저. ``ConditionSearchManager`` 와 **동일한 public API** 를 제공한다: - start() / stop() - get_universe_for(strategy_id) / get_candidates_for(strategy_id) - _configs (BaseStrategy._is_strategy_registered 판별용) """ def __init__( self, *, app_key: str, app_secret: str, is_mock: bool, configs: Optional[List[Dict]] = None, db=None, on_change=None, shared_ws: Any = None, ): # 부모 초기화: client 는 REST 미사용이므로 None, user_id 는 로깅용 placeholder. # configs 정규화·EXIT grace·name_map·_lock 등은 부모가 세팅. super().__init__( client=None, user_id="KIWOOM", configs=configs, db=db, on_change=on_change, ) self._app_key = (app_key or "").strip() self._app_secret = (app_secret or "").strip() self._is_mock = bool(is_mock) self._token: Optional[str] = None # 시세 WS(KiwoomWebSocketPriceCache) 와 **단일 세션 공유** — 별도 접속 시 Bye 루프 self._shared_ws: Any = shared_ws self._shared_mode: bool = False self._shared_handlers_bound: bool = False # WS URL (실전/모의) — env 로 오버라이드 가능. if self._is_mock: self._ws_url = ( get_env_from_db( "KIWOOM_WS_URL_MOCK", "wss://mockapi.kiwoom.com:10000/api/dostk/websocket", ) or "wss://mockapi.kiwoom.com:10000/api/dostk/websocket" ).strip() else: self._ws_url = ( get_env_from_db( "KIWOOM_WS_URL_REAL", "wss://api.kiwoom.com:10000/api/dostk/websocket", ) or "wss://api.kiwoom.com:10000/api/dostk/websocket" ).strip() # 실시간 재등록 대기(초) — 재접속 시 사용. self._reconnect_backoff = float(get_env_int("KIWOOM_COND_RECONNECT_SEC", 5)) # 키움 전용 상태 self._kw_lock = threading.Lock() self._ws = None self._ws_thread: Optional[threading.Thread] = None # seq → {code(정규화): name} (삽입순 유지 = HTS 응답 순서) self._seq_codes: Dict[str, "Dict[str, str]"] = {} # seq → [strategy_id, ...] (같은 seq 를 공유하는 전략) self._sid_by_seq: Dict[str, List[str]] = {} # 이번 접속에서 실시간 등록할 unique seq 목록 (CNSRLST 해결 후 채움) self._active_seqs: List[str] = [] # 최초 CNSRLST 처리 + CNSRREQ 시도 완료 신호 (start() 동기 대기용) self._ready = threading.Event() self._start_ok = False # CNSRREQ 발송·응답 추적 (연속 발송 시 응답 누락 → 재발송) self._cnsrreq_pending: Set[str] = set() self._cnsrreq_confirmed: Set[str] = set() self._cnsrreq_retry_timer: Optional[threading.Timer] = None # CNSRCLR 해제 완료(rc=0) 응답 대기 이벤트 추적 self._cnsrclr_ack_events: Dict[str, threading.Event] = {} # 900003(이미 등록) 에 대한 CNSRCLR→재요청은 seq당 사이클 1회만 (무한 재시도 금지) self._cnsr_dup_cleared: Set[str] = set() # CNSRCLR→REQ 파이프라인에서 send 대상 WS 캐시 self._cnsr_send_ws: Any = None # ------------------------------------------------------------------ # Public API (오버라이드) — 부모 start() 는 REST 폴링이므로 사용 안 함 # ------------------------------------------------------------------ def start(self) -> bool: """토큰 발급 → 키움 WS 접속 → CNSRLST 로 seq 해결 → CNSRREQ 실시간 등록.""" if not self._configs: logger.info("키움 조건검색 configs 비어 있음 → 매니저 비활성") return False if not (self._app_key and self._app_secret): logger.warning("키움 앱키/시크릿 누락 → 조건검색 매니저 비활성") return False # 시세 WS 가 이미 떠 있으면 **같은 소켓**으로 조건검색 (키움 1세션 정책) if self._shared_ws is not None and getattr(self._shared_ws, "is_available", lambda: False)(): return self._start_shared() try: import websocket # noqa: F401 (websocket-client 존재 확인) except Exception as e: logger.warning("websocket-client 미설치 → 키움 조건검색 비활성: %s", e) return False return self._start_own_connection() def _start_shared(self) -> bool: """KiwoomWebSocketPriceCache 세션에 CNSR* 핸들러만 부착 (별도 접속 없음).""" self._shared_mode = True self._running = True self._token = _get_kiwoom_token_cached( self._app_key, self._app_secret, self._is_mock ) if not self._token: logger.warning("키움 토큰 발급 실패 → 조건검색 매니저 비활성") try: from kis_trader.utils.ops_alert import ops_alert ops_alert( "token_kiwoom_cond", "키움 조건검색 토큰 실패 → 매니저 OFF", detail="MOMENTUM/SHORT 등 kiwoom_condition 유니버스 위험", level="critical", session_only=False, ) except Exception: pass return False self._bind_shared_handlers() # 이미 LOGIN 된 상태면 즉시 CNSRLST if getattr(self._shared_ws, "is_authenticated", lambda: False)(): self._send_cnsrlst() ready_timeout = float(get_env_int("KIWOOM_COND_START_TIMEOUT_SEC", 10)) self._ready.wait(timeout=ready_timeout) if self._start_ok: logger.info( "✅ 키움 조건검색 실시간 시작 [공유WS] (%d개, mock=%s, exit_grace=%ds, history=%s)", len(self._active_seqs), self._is_mock, int(self._exit_grace_sec), "ON" if (self.history_enabled and self.db is not None) else "OFF", ) else: logger.warning( "⚠️ 키움 조건검색 [공유WS] 초기 등록 미완료(타임아웃) — LOGIN 재접속 시 자동 재시도" ) return True def _start_own_connection(self) -> bool: """레거시: 단독 WS (shared_ws 없을 때만 — 중복 접속 주의).""" self._token = _get_kiwoom_token_cached( self._app_key, self._app_secret, self._is_mock ) if not self._token: logger.warning("키움 토큰 발급 실패 → 조건검색 매니저 비활성") try: from kis_trader.utils.ops_alert import ops_alert ops_alert( "token_kiwoom_cond", "키움 조건검색 토큰 실패 → 매니저 OFF", detail="단독 WS 경로", level="critical", session_only=False, ) except Exception: pass return False self._running = True self._ws_thread = threading.Thread( target=self._run_ws_forever, daemon=True, name="KwCondSearch" ) self._ws_thread.start() ready_timeout = float(get_env_int("KIWOOM_COND_START_TIMEOUT_SEC", 10)) self._ready.wait(timeout=ready_timeout) if self._start_ok: logger.info( "✅ 키움 조건검색 실시간 시작 (%d개, mock=%s, exit_grace=%ds, history=%s)", len(self._active_seqs), self._is_mock, int(self._exit_grace_sec), "ON" if (self.history_enabled and self.db is not None) else "OFF", ) else: logger.warning( "⚠️ 키움 조건검색 실시간 초기 등록 미완료(타임아웃) — 백그라운드 재시도 지속" ) return True def stop(self) -> None: self._running = False self._cancel_cnsrreq_retry() # 종료 전 등록된 조건식 CNSRCLR 발송 (시세 WS의 종목 REMOVE와 대칭, 죽은 소켓이면 생략) try: target_ws = self._shared_ws if self._shared_mode else self._ws is_connected = getattr(target_ws, "_connected", True) if target_ws is not None else False if target_ws is not None and is_connected and self._active_seqs: logger.info("🛑 키움 조건검색 매니저 종료 전 CNSRCLR 발송 (seq=%s)", self._active_seqs) self._clear_active_seqs(target_ws, self._active_seqs) except Exception as e: logger.warning("키움 조건검색 종료 전 CNSRCLR 발송 실패: %s", e) if self._shared_mode: self._unbind_shared_handlers() return try: if self._ws is not None: self._ws.close() except Exception: pass def _bind_shared_handlers(self) -> None: if self._shared_handlers_bound or not self._shared_ws: return ws = self._shared_ws ws.register_trnm_handler("CNSRLST", self._on_shared_trnm) ws.register_trnm_handler("CNSRREQ", self._on_shared_trnm) ws.register_trnm_handler("CNSRCLR", self._on_shared_trnm) ws.register_trnm_handler("REAL", self._on_shared_real) ws.add_on_login_callback(self._on_shared_login) self._shared_handlers_bound = True logger.info("🔗 키움 조건검색 → 시세 WS 세션 공유 (중복 접속 방지)") def _unbind_shared_handlers(self) -> None: if not self._shared_handlers_bound or not self._shared_ws: return ws = self._shared_ws ws.unregister_trnm_handler("CNSRLST", self._on_shared_trnm) ws.unregister_trnm_handler("CNSRREQ", self._on_shared_trnm) ws.unregister_trnm_handler("CNSRCLR", self._on_shared_trnm) ws.unregister_trnm_handler("REAL", self._on_shared_real) ws.remove_on_login_callback(self._on_shared_login) self._shared_handlers_bound = False def _send_cnsrlst(self) -> None: if self._shared_ws: self._shared_ws.send_json({"trnm": "CNSRLST"}) def _on_shared_login(self, ws) -> None: """시세 WS 재접속마다 조건식 목록 재조회 → (CLR 후) CNSRREQ 재등록. 키움은 새벽 등 WS ``Bye`` 후에도 계정 단위로 조건검색 등록이 남을 수 있음. 재LOGIN 시에는 기존 pending 재시도 타이머를 끊고, CNSRLST→CLR→REQ 로 맞춘다. """ if not self._running: return self._cancel_cnsrreq_retry() self._ready.clear() self._start_ok = False with self._kw_lock: self._cnsr_dup_cleared.clear() self._cnsrreq_pending.clear() self._cnsrreq_confirmed.clear() try: ws.send(json.dumps({"trnm": "CNSRLST"})) logger.debug("키움 조건검색 [공유WS] LOGIN → CNSRLST") except Exception as e: logger.debug("키움 조건검색 CNSRLST 발송 실패: %s", e) def _trigger_cnsrclr_ack(self, seq) -> None: if not seq: return with self._kw_lock: ev = self._cnsrclr_ack_events.get(str(seq)) if ev: ev.set() def _on_shared_trnm(self, ws, msg: dict) -> None: trnm = str(msg.get("trnm") or "") log_kiwoom_api_msg(logger, trnm or "?", msg, prefix="조건검색") if trnm == "CNSRLST": self._handle_condition_list(ws, msg.get("data") or []) elif trnm == "CNSRREQ": self._handle_cnsrreq(msg) elif trnm == "CNSRCLR": seq_val = _extract_cnsr_seq(msg) rc = msg.get("return_code") rm = msg.get("return_msg") if str(rc or "").strip() not in ("0", "0.0", ""): logger.warning( "키움 CNSRCLR 실패 seq=%s rc=%s msg=%s", seq_val, rc, rm, ) else: logger.info( "키움 CNSRCLR OK seq=%s rc=%s msg=%s", seq_val, rc, rm, ) self._trigger_cnsrclr_ack(seq_val) def _on_shared_real(self, ws, msg: dict) -> None: """조건검색 편입/이탈 REAL — 843 필드 있는 항목만 처리.""" rows = msg.get("data") or [] cond_rows = [] for it in rows: vals = it.get("values") if isinstance(it, dict) else None if isinstance(vals, dict) and "843" in vals: cond_rows.append(it) if cond_rows: self._handle_real(cond_rows) # ------------------------------------------------------------------ # WS 라이프사이클 # ------------------------------------------------------------------ def _run_ws_forever(self) -> None: """접속 → (끊기면) 백오프 후 재접속 루프. 재접속 시 실시간 재등록.""" import websocket # websocket-client while self._running: try: self._ws = websocket.WebSocketApp( self._ws_url, on_open=self._on_open, on_message=self._on_message, on_error=self._on_error, on_close=self._on_close, ) # ping_interval=0 : 키움은 서버가 PING 을 보내면 echo 하는 방식. self._ws.run_forever(ping_interval=0) except Exception as e: logger.debug("키움 조건검색 WS 예외: %s", e) if not self._running: break # 재접속 대기 (중단 감지 해상도 0.5s) deadline = time.time() + self._reconnect_backoff while self._running and time.time() < deadline: time.sleep(0.5) def _on_open(self, ws) -> None: try: ws.send(json.dumps({"trnm": "LOGIN", "token": self._token})) logger.debug("키움 조건검색 LOGIN 발송") except Exception as e: logger.debug("키움 조건검색 LOGIN 발송 실패: %s", e) def _on_error(self, ws, err) -> None: logger.debug("키움 조건검색 WS 오류: %s", err) def _on_close(self, ws, code, msg) -> None: logger.debug("키움 조건검색 WS 종료 (code=%s)", code) def _on_message(self, ws, message) -> None: try: data = json.loads(message) except Exception: return trnm = data.get("trnm") # 키움 WS keep-alive: 받은 PING 을 그대로 돌려보냄 if trnm == "PING": try: ws.send(message) except Exception: pass return if trnm == "LOGIN": if str(data.get("return_code")) in ("0", "0.0"): logger.debug("키움 조건검색 LOGIN OK → CNSRLST") try: ws.send(json.dumps({"trnm": "CNSRLST"})) except Exception: pass else: logger.warning("키움 조건검색 LOGIN 실패: %s", data.get("return_msg")) return if trnm == "CNSRLST": self._handle_condition_list(ws, data.get("data") or []) return if trnm == "CNSRREQ": self._handle_cnsrreq(data) return if trnm == "REAL": self._handle_real(data.get("data") or []) return if trnm == "CNSRCLR": seq_val = _extract_cnsr_seq(data) logger.debug( "키움 조건검색 CNSRCLR 응답: seq=%s rc=%s msg=%s", seq_val, data.get("return_code"), data.get("return_msg"), ) self._trigger_cnsrclr_ack(seq_val) return # ------------------------------------------------------------------ # 조건식 목록 → seq 해결 → 실시간 등록 # ------------------------------------------------------------------ @staticmethod def _seq_name(item): """CNSRLST data 항목: [seq, name] 배열 또는 {seq,name} dict 모두 허용.""" if isinstance(item, (list, tuple)): seq = str(item[0]) if len(item) > 0 else "" name = str(item[1]) if len(item) > 1 else "" return seq.strip(), name.strip() if isinstance(item, dict): return str(item.get("seq") or "").strip(), str(item.get("name") or "").strip() return "", "" def _handle_condition_list(self, ws, rows: List) -> None: """CNSRLST 응답으로 name→seq 해결 후, 전략별 seq 확정 + CLR→CNSRREQ.""" name_to_seq: Dict[str, str] = {} for it in rows: seq, name = self._seq_name(it) if name: name_to_seq[name.strip().lower()] = seq if rows: logger.info( "키움 저장 조건식 %d개: %s", len(rows), ", ".join( f"{self._seq_name(x)[0]}:{self._seq_name(x)[1] or '?'}" for x in rows ), ) # 전략별 seq 확정 (seq 우선, 없으면 name 으로 해결) sid_by_seq: Dict[str, List[str]] = {} for cfg in self._configs: sid = cfg["strategy_id"] seq = (cfg.get("seq") or "").strip() if not seq and cfg.get("name"): seq = name_to_seq.get(cfg["name"].strip().lower(), "") if not seq: logger.warning( "⚠️ 키움 조건식 seq 해결 실패 (strategy=%s name=%s) → 이 전략 폴백", sid, cfg.get("name"), ) continue cfg["seq"] = seq # 해결 결과 반영 sid_by_seq.setdefault(seq, []) if sid not in sid_by_seq[seq]: sid_by_seq[seq].append(sid) logger.info( "🔗 키움 조건식 매핑: strategy=%s seq=%s name=%s", sid, seq, cfg.get("name") or "?", ) with self._kw_lock: self._sid_by_seq = sid_by_seq self._active_seqs = list(sid_by_seq.keys()) # 실시간(search_type=1) 등록 — 유령등록 방지로 CLR 후 순차 REQ self._send_cnsrreq_all(ws) def _cancel_cnsrreq_retry(self) -> None: if self._cnsrreq_retry_timer: try: self._cnsrreq_retry_timer.cancel() except Exception: pass self._cnsrreq_retry_timer = None def _send_one_cnsrclr(self, ws, seq: str) -> bool: """단일 seq CNSRCLR(실시간 해제) 발송 — Bye 후 유령 등록 제거용.""" if ws is not None and not getattr(ws, "_connected", True): logger.debug("키움 CNSRCLR 스킵 (소켓 연결 끊김): seq=%s", seq) return False with self._kw_lock: ev = threading.Event() self._cnsrclr_ack_events[str(seq)] = ev payload = {"trnm": "CNSRCLR", "seq": seq} try: if self._shared_mode and self._shared_ws: return bool(self._shared_ws.send_json(payload)) target = ws if ws is not None else self._ws if target is None or not getattr(target, "_connected", True): return False target.send(json.dumps(payload)) return True except Exception as e: logger.warning("키움 CNSRCLR 발송 실패 (seq=%s): %s", seq, e) return False def _clear_active_seqs(self, ws, seqs: Optional[List[str]] = None) -> None: """active(또는 주어진) seq 전부 CNSRCLR — 간격은 env (하드코딩 금지).""" if seqs is None: with self._kw_lock: seqs = list(self._active_seqs) else: seqs = list(seqs) try: seqs.sort(key=lambda x: int(x) if str(x).isdigit() else 0) except Exception: pass if not seqs: return gap_lo = float(get_env_float("KIWOOM_CNSRCLR_GAP_MIN_SEC", 0.3)) gap_hi = float(get_env_float("KIWOOM_CNSRCLR_GAP_MAX_SEC", 0.6)) if gap_hi < gap_lo: gap_lo, gap_hi = gap_hi, gap_lo logger.info("🧹 키움 조건검색 CNSRCLR 선행 (seq=%s)", seqs) for i, seq in enumerate(seqs): if i > 0: time.sleep(random.uniform(gap_lo, gap_hi)) self._send_one_cnsrclr(ws, seq) def _send_one_cnsrreq(self, ws, seq: str) -> bool: """단일 seq CNSRREQ 발송.""" payload = { "trnm": "CNSRREQ", "seq": seq, "search_type": "1", "stex_tp": "K", } try: if self._shared_mode and self._shared_ws: return bool(self._shared_ws.send_json(payload)) ws.send(json.dumps(payload)) return True except Exception as e: log_kiwoom_ws( logger, "cnsrreq_send_fail", level="warning", trnm="CNSRREQ", extra=f"seq={seq} err={e}", force=True, ) logger.warning("키움 CNSRREQ 발송 실패 (seq=%s): %s", seq, e) return False def _send_cnsrreq_all(self, ws) -> None: """active seq: (옵션) CNSRCLR → settle → 순차 CNSRREQ. 미응답만 유한 재시도.""" self._cancel_cnsrreq_retry() self._cnsr_send_ws = ws with self._kw_lock: seqs = list(self._active_seqs) self._cnsr_dup_cleared.clear() try: seqs.sort(key=lambda x: int(x)) except Exception: pass # Bye 후 유령 등록(900003) 방지: 재등록 전에 항상 클리어 (기본 ON) if get_env_bool("KIWOOM_CNSRCLR_BEFORE_REQ", True): self._clear_active_seqs(ws, seqs) settle = float(get_env_float("KIWOOM_CNSRCLR_SETTLE_SEC", 2.0)) if settle > 0: start_ts = time.time() while time.time() - start_ts < settle: with self._kw_lock: all_acked = all( self._cnsrclr_ack_events.get(str(s), threading.Event()).is_set() for s in seqs ) if all_acked: logger.debug( "⚡ 키움 CNSRCLR 응답 수신 완료(초기) — 즉시 CNSRREQ 진행 (%.2fs)", time.time() - start_ts, ) break time.sleep(0.05) gap_lo = float(get_env_float("KIWOOM_CNSRREQ_GAP_MIN_SEC", 0.8)) gap_hi = float(get_env_float("KIWOOM_CNSRREQ_GAP_MAX_SEC", 1.5)) if gap_hi < gap_lo: gap_lo, gap_hi = gap_hi, gap_lo with self._kw_lock: self._cnsrreq_pending = set(seqs) self._cnsrreq_confirmed.clear() sent = 0 for i, seq in enumerate(seqs): if i > 0: time.sleep(random.uniform(gap_lo, gap_hi)) if self._send_one_cnsrreq(ws, seq): sent += 1 logger.debug("키움 CNSRREQ 발송 (seq=%s)", seq) self._start_ok = sent > 0 self._ready.set() self._schedule_cnsrreq_retry(ws, attempt=1) def _schedule_cnsrreq_retry(self, ws, *, attempt: int) -> None: """CNSRREQ 응답이 안 온 seq 만 간격 두고 재발송 (상한 초과 시 중단 · 무한 금지).""" max_retries = get_env_int("KIWOOM_CNSRREQ_MAX_RETRIES", 30) retry_delay = float(get_env_float("KIWOOM_CNSRREQ_RETRY_SEC", 3.0)) gap_lo = float(get_env_float("KIWOOM_CNSRREQ_GAP_MIN_SEC", 0.8)) gap_hi = float(get_env_float("KIWOOM_CNSRREQ_GAP_MAX_SEC", 1.5)) if gap_hi < gap_lo: gap_lo, gap_hi = gap_hi, gap_lo self._cancel_cnsrreq_retry() def _retry() -> None: if not self._running: return with self._kw_lock: missing = sorted( self._cnsrreq_pending - self._cnsrreq_confirmed, key=lambda x: int(x) if str(x).isdigit() else 0, ) if not missing: return if attempt > max_retries: logger.warning( "⚠️ 키움 CNSRREQ 미응답 seq=%s — 최대 재시도 초과 (추가 재시도 없음)", missing, ) # pending 비워 이후 타이머/재발송이 남지 않게 함 with self._kw_lock: for s in missing: self._cnsrreq_pending.discard(s) return logger.warning( "⚠️ 키움 CNSRREQ 미응답 seq=%s → %.0fs 후 재발송 (%d/%d)", missing, retry_delay, attempt, max_retries, ) for i, seq in enumerate(missing): if i > 0: time.sleep(random.uniform(gap_lo, gap_hi)) if get_env_bool("KIWOOM_CNSRCLR_BEFORE_REQ", True): self._send_one_cnsrclr(ws, seq) start_ts = time.time() while time.time() - start_ts < 1.0: with self._kw_lock: ev = self._cnsrclr_ack_events.get(str(seq)) if ev and ev.is_set(): break time.sleep(0.05) self._send_one_cnsrreq(ws, seq) self._schedule_cnsrreq_retry(ws, attempt=attempt + 1) self._cnsrreq_retry_timer = threading.Timer(retry_delay, _retry) self._cnsrreq_retry_timer.daemon = True self._cnsrreq_retry_timer.start() def _handle_cnsr_already_registered(self, seq: str, data: Dict) -> None: """rc=900003: seq당 1회만 CNSRCLR→CNSRREQ. 두 번째면 포기(무한 금지) 대신 백그라운드 고정 재시도로 인계.""" with self._kw_lock: already = seq in self._cnsr_dup_cleared if not already: self._cnsr_dup_cleared.add(seq) if already: logger.warning( "⚠️ 키움 CNSRREQ 900003 재발 (seq=%s) — CLR 후에도 충돌, 즉시 시도 중단 및 백그라운드 고정 대기(3초) 유지 msg=%s", seq, data.get("return_msg"), ) # 900003 두 번째 충돌 시 즉시 해당 조건을 폐기(discard)하여 유니버스 0으로 만드는 로직 퇴출. # with self._kw_lock: # self._cnsrreq_pending.discard(seq) return ws = self._cnsr_send_ws if ws is None and self._shared_mode: ws = self._shared_ws if ws is None: ws = self._ws logger.warning( "⚠️ 키움 CNSRREQ 이미등록(900003) seq=%s → CNSRCLR 1회 후 재요청", seq, ) self._send_one_cnsrclr(ws, seq) settle = float(get_env_float("KIWOOM_CNSRCLR_SETTLE_SEC", 2.0)) if settle > 0: start_ts = time.time() while time.time() - start_ts < settle: with self._kw_lock: ev = self._cnsrclr_ack_events.get(str(seq)) if ev and ev.is_set(): logger.debug("⚡ 키움 CNSRCLR 응답 수신 완료(900003 회복) — 즉시 CNSRREQ 진행 (seq=%s)", seq) break time.sleep(0.05) self._send_one_cnsrreq(ws, seq) def _handle_cnsrreq(self, data: Dict) -> None: """CNSRREQ 초기 응답: 현재 매칭 종목 리스트로 seq universe 초기화.""" rc = str(data.get("return_code")) seq = _extract_cnsr_seq(data) # seq 가 응답에 없을 수 있음 → active_seqs 가 1개면 그걸로 간주 if not seq: with self._kw_lock: if len(self._active_seqs) == 1: seq = self._active_seqs[0] if rc not in ("0", "0.0"): msg = str(data.get("return_msg") or "") is_dup = ( rc == _CNSR_ALREADY_REGISTERED_RC or "이미 등록" in msg ) logger.warning( "키움 CNSRREQ 실패 (seq=%s) rc=%s msg=%s", seq, rc, msg, ) if is_dup and seq: self._handle_cnsr_already_registered(seq, data) return # 그 외 에러: pending 에서 제거 → '미응답'으로 오인·무한 재발송 방지 if seq: with self._kw_lock: self._cnsrreq_pending.discard(seq) return if not seq: return codes: "Dict[str, str]" = {} for it in (data.get("data") or []): code = self._extract_code(it) if code: codes[code] = code # 초기 응답엔 종목명 없음 → code 로 대체 with self._kw_lock: self._seq_codes[seq] = codes self._cnsrreq_confirmed.add(seq) self._cnsrreq_pending.discard(seq) self._publish_seq(seq) logger.info( "✅ 키움 실시간 등록 (seq=%s) 초기 매칭 %d종목", seq, len(codes) ) def _handle_real(self, rows: List) -> None: """REAL push: 843=I(편입)/D(이탈), 9001=종목코드. seq 별 universe 갱신.""" touched: Set[str] = set() job_events: List[Dict[str, Any]] = [] with self._kw_lock: for it in rows: vals = it.get("values") if isinstance(it, dict) else None if not isinstance(vals, dict): continue code = _normalize_code(vals.get("9001")) if not code: continue ins_del = str(vals.get("843") or "").strip().upper() # 조건식 번호는 values.841. item 은 종목코드(9001 과 동일)인 경우가 많아 seq 로 쓰면 안 됨. seq = str(vals.get("841") or "").strip() if not seq and isinstance(it, dict): raw_item = str(it.get("seq") or "").strip() if raw_item and raw_item in self._sid_by_seq: seq = raw_item if not seq: if len(self._active_seqs) == 1: seq = self._active_seqs[0] else: logger.debug( "키움 조건 REAL skip — seq 미식별 code=%s 843=%s active=%s", code, ins_del, self._active_seqs, ) continue bucket = self._seq_codes.setdefault(seq, {}) if ins_del == "D": bucket.pop(code, None) else: # "I" 또는 기타 → 편입으로 처리 (삽입순 유지) if code not in bucket: bucket[code] = code if ins_del in ("I", "D"): bt = str(vals.get("20") or "").strip() # HHMMSS for sid in list(self._sid_by_seq.get(seq, [])): job_events.append({ "strategy_id": sid, "code": code, "job_flag": ins_del, "broker_time": bt, }) touched.add(seq) if job_events and self.db is not None: try: from .condition_job_events import insert_condition_job_event from .condition_common import normalize_job_flag for ev in job_events: insert_condition_job_event( self.db, strategy_id=ev["strategy_id"], code=ev["code"], job_flag=normalize_job_flag(ev["job_flag"]), broker="kiwoom", name=ev["code"], broker_time=ev.get("broker_time") or "", source="kiwoom_real", ) except Exception as e: logger.debug("키움 job_event: %s", e) for seq in touched: self._publish_seq(seq) @staticmethod def _extract_code(item) -> str: """조건검색 결과 항목에서 종목코드 추출 (9001 우선, jmcode 폴백).""" if isinstance(item, dict): return _normalize_code(item.get("9001") or item.get("jmcode")) if isinstance(item, (list, tuple)) and item: return _normalize_code(item[0]) return "" # ------------------------------------------------------------------ # seq universe → 전략별 반영 (부모 _apply_result 재사용) # ------------------------------------------------------------------ def _publish_seq(self, seq: str) -> None: """seq 의 현재 종목집합을 그 seq 를 쓰는 모든 전략에 반영. 부모 ``_apply_result(strategy_id, rows)`` 를 그대로 호출 → enters/exits 계산·순서·EXIT grace·스냅샷 저장까지 KIS 와 동일하게 처리. 이중 적재 (기본 ON): 실매 ``UNIVERSE_SOURCE`` 가 kiwoom_condition 이 아니어도 (예: SCALP/BREAKOUT=ls_condition) RAM·``target_candidates_history`` 는 갱신한다. → 다전략 동시 운영 시 키움/LS 이력 둘 다 쌓기 · 런타임 소스 전환 대비. ``on_change`` 는 main 미주입이라 WS 구독 부작용 없음. 끄기: ``KIWOOM_CONDITION_DUAL_HISTORY=false`` (레거시: 실매 소스만 반영). """ with self._kw_lock: bucket = dict(self._seq_codes.get(seq, {})) sids = list(self._sid_by_seq.get(seq, [])) # rows: 삽입순(HTS 응답 순서) 유지 → _build_ordered_universe 가 그대로 사용 rows = [] for c, n in bucket.items(): disp = str(n or c).strip() or c if disp == c and self.db is not None: try: from ..utils.stock_name import resolve_stock_display_name disp = resolve_stock_display_name(self.db, c, fallback=c) except Exception: disp = c rows.append({"code": c, "name": disp}) from ..utils.universe_source import universe_source_active dual = get_env_bool("KIWOOM_CONDITION_DUAL_HISTORY", True) for sid in sids: if not dual and not universe_source_active(sid, "kiwoom_condition"): continue try: self._apply_result(sid, rows) except Exception as e: logger.debug("키움 조건검색 _apply_result 예외 (%s): %s", sid, e)