""" kis_trader/network/condition_manager.py — KIS 조건검색 기반 동적 유니버스 =========================================================================== 팩트 체크 먼저: * KIS 는 ``H0UPANC0`` 웹소켓으로 "조건검색 실시간" 을 주지 않는다. H0UPANC0 는 **업종별 예상체결** TR 이다. 인터넷 블로그/LLM 답변에 자주 보이는 "조건검색 웹소켓" 은 대부분 키움 OpenAPI+ 쪽 이야기. * KIS 공식 경로는 REST 두 개뿐: /quotations/psearch-title → 서버 저장 조건식 목록 (HHKST03900300) /quotations/psearch-result → 특정 조건식 현재 결과 (HHKST03900400) * 따라서 REST 폴링이 유일한 방법. 기본 폴링 주기는 ``CONDITION_POLL_INTERVAL_SEC`` (기본 10초). 10초면 종목당 하루 ~2,340 호출로 429 안전 여유 충분. v2 변경점 (다중 조건식 지원): * 전략마다 다른 조건식을 쓸 수 있도록 ``configs`` 인자 추가: configs = [ {"strategy_id": "SCALP", "name": "체결강도급등", "seq": "0"}, {"strategy_id": "SHORT", "name": "꼬리달린봉", "seq": ""}, # seq 는 name 으로 자동 해결 {"strategy_id": "BREAKOUT", "name": "우상향돌파", "seq": ""}, ] * 같은 조건식을 여러 전략이 공유해도 OK (seq 가 같으면 REST 1번만 호출) * 전략별 get_universe_for / get_candidates_for 제공 * **변동(ENTER/EXIT) 감지 tick 마다** ``target_candidates_history`` 에 초단위 ``event_time`` (YYYY-MM-DD HH:MM:SS) 으로 풀 스냅샷을 INSERT. (strategy_id 컬럼은 ``TradeDBExt.insert_condition_universe_snapshot`` 이 자동 마이그레이션.) 백테스트는 ``TradeDBExt.get_universe_by_candle_time()`` 으로 "그 1분봉 시점에 봇이 보던 유니버스" 를 재현. 사용 (권장 — multi): cm = ConditionSearchManager( client=kis_client, user_id="HTSID", configs=[ {"strategy_id": "SCALP", "name": "체결강도급등"}, {"strategy_id": "BREAKOUT", "seq": "0"}, ], db=db, ) cm.start() codes: set = cm.get_universe_for("SCALP") 사용 (legacy — single, 기존 호출 호환): cm = ConditionSearchManager( client=kis_client, user_id="HTSID", condition_name="우상향돌파", ) """ from __future__ import annotations import random import threading import time from datetime import datetime as dt from typing import Callable, Dict, List, Optional, Set from ..utils.env import get_env_bool, get_env_int from ..utils.logger import get_logger logger = get_logger("kis_trader.cond") class ConditionSearchManager: """KIS 조건검색 폴링 매니저. 여러 조건식을 동시에 병렬 관리.""" def __init__( self, *, client, user_id: str, configs: Optional[List[Dict]] = None, condition_name: Optional[str] = None, condition_seq: Optional[str] = None, on_change: Optional[Callable[[str, Set[str], Set[str], Set[str]], None]] = None, poll_interval_sec: Optional[float] = None, db=None, ): self.client = client self.user_id = (user_id or "").strip() self.on_change = on_change self.db = db # 히스토리 저장용 (선택) self.poll_interval = float( poll_interval_sec if poll_interval_sec is not None else get_env_int("CONDITION_POLL_INTERVAL_SEC", 10) ) self.history_enabled = get_env_bool( "CONDITION_HISTORY_SAVE", get_env_bool("UNIVERSE_HISTORY_SAVE", True), ) # EXIT grace period: 한 번 빠진 종목을 N초간 universe 에 keep. # 단발성 EXIT/RE-ENTER 회전을 흡수해 WS 구독 해제 → 캐시·갭보정 리셋 # → resubscribe 후 데이터 부족으로 매수 시그널 못내는 사이클을 차단. # 기본 0 = 비활성 (기존 동작과 동일). 권장 60. self._exit_grace_sec = float(get_env_int("CONDITION_EXIT_GRACE_SEC", 0)) # strategy_id → {code: first_missing_at_epoch} self._pending_exit: Dict[str, Dict[str, float]] = {} # 설정 정규화: legacy(single) → multi 형식으로 흡수 self._configs: List[Dict] = [] if configs: for c in configs: sid = str(c.get("strategy_id") or "").strip().upper() nm = (c.get("name") or "").strip() or None sq = (c.get("seq") or "").strip() or None if not sid or (not nm and not sq): continue self._configs.append({"strategy_id": sid, "name": nm, "seq": sq}) elif condition_name or condition_seq: self._configs.append({ "strategy_id": "DEFAULT", "name": (condition_name or "").strip() or None, "seq": (condition_seq or "").strip() or None, }) self._thread: Optional[threading.Thread] = None self._running = False # 상태 self._current: Dict[str, Set[str]] = {} # strategy_id → code set self._current_order: Dict[str, List[str]] = {} # strategy_id → 매수 우선순위(HTS 응답 순) self._name_map: Dict[str, str] = {} # code → name (전역) # 초기 tick 에서 "빈 set → 첫 결과" 를 변동으로 간주해 1회는 저장 self._initialized: Set[str] = set() self._lock = threading.Lock() # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------ def start(self) -> bool: """조건식 seq 를 해결하고 폴링 쓰레드 기동. 유효 조건식 0개면 False.""" if not self.user_id: logger.warning("조건검색 user_id 누락 → 비활성") return False if not self._configs: logger.info("조건검색 configs 비어 있음 → 매니저 비활성") return False # seq 해결 (이름 → seq). 1번만 전체 목록 호출해서 캐시. name_to_seq = self._fetch_seq_map() valid = [] for cfg in self._configs: if not cfg.get("seq") and cfg.get("name"): sq = name_to_seq.get(cfg["name"]) if sq: cfg["seq"] = sq if cfg.get("seq"): valid.append(cfg) logger.info( "🔗 조건식 매핑: strategy=%s seq=%s name=%s", cfg["strategy_id"], cfg["seq"], cfg.get("name") or "?", ) else: logger.warning( "⚠️ 조건식 seq 해결 실패 (strategy=%s name=%s) → 이 전략은 폴백", cfg["strategy_id"], cfg.get("name"), ) self._configs = valid if not self._configs: logger.warning("유효 조건식 0개 → 조건검색 매니저 비활성") return False self._running = True self._thread = threading.Thread( target=self._loop, daemon=True, name="CondSearch" ) self._thread.start() logger.info( "✅ 조건검색 폴링 시작 (%d개, interval=%ds, exit_grace=%ds, history=%s)", len(self._configs), int(self.poll_interval), int(self._exit_grace_sec), "ON" if (self.history_enabled and self.db is not None) else "OFF", ) return True def stop(self) -> None: self._running = False # ── 전략별 조회 ──────────────────────────────────────────── def get_universe_for(self, strategy_id: str) -> Set[str]: sid = (strategy_id or "").upper() with self._lock: return set(self._current.get(sid, set())) def get_candidates_for(self, strategy_id: str) -> List[Dict]: """BaseStrategy._load_candidates 와 호환되는 dict 리스트 반환.""" sid = (strategy_id or "").upper() with self._lock: codes = list(self._current_order.get(sid) or []) if not codes: codes = list(self._current.get(sid, set())) nm = dict(self._name_map) # 전략별 기본 필터 플래그는 True 로 열어둔다 (전략 쪽 _candidate_filter 가 판단) out = [] for c in codes: out.append({ "code": c, "name": nm.get(c, c), "scalp_on": True, "tail_on": True, "updow_on": True, "score": 0.0, "price": 0.0, }) return out # ── 하위 호환 (Breakout 단일 매니저용) ─────────────────── def get_universe(self) -> Set[str]: """모든 전략 유니버스의 합집합 (heartbeat/총량 로그용).""" with self._lock: out: Set[str] = set() for s in self._current.values(): out |= s return out def get_candidates(self) -> List[Dict]: """기본 호출 시 첫 번째 설정된 전략 유니버스 반환 (하위호환).""" if not self._configs: return [] return self.get_candidates_for(self._configs[0]["strategy_id"]) # ------------------------------------------------------------------ # 내부 # ------------------------------------------------------------------ @staticmethod def _row_name(row: Dict) -> str: """ KIS psearch-title 응답의 '조건식 이름' 추출. 실제 응답 키는 ``condition_nm`` (예: {"seq":"0","condition_nm":"돌파_초반강세",...}). 과거/문서상 변형 키도 모두 허용해 안전하게 폴백. """ for k in ("condition_nm", "condition_name", "cond_nm", "user_cnd_nm"): v = row.get(k) if v is not None and str(v).strip(): return str(v).strip() return "" def _fetch_seq_map(self) -> Dict[str, str]: """서버 저장 조건식 목록 1회 호출 → name→seq 맵.""" try: lst = self.client.get_condition_list(self.user_id) or [] except Exception as e: logger.error("조건식 목록 조회 예외: %s", e) return {} if not lst: logger.warning("조건식 목록이 비어있음 (user_id=%s)", self.user_id) return {} logger.info( "저장된 조건식 %d개: %s", len(lst), ", ".join(f"{x.get('seq')}:{self._row_name(x) or '?'}" for x in lst), ) return { self._row_name(row): str(row.get("seq") or "").strip() for row in lst if self._row_name(row) } def _loop(self) -> None: while self._running: try: self._tick_all() except Exception as e: logger.error("조건검색 루프 예외: %s", e) # 서버 부하 방지 지터 — 주기 대비 10% 수준 jitter = min(1.0, self.poll_interval * 0.1) sleep_sec = self.poll_interval + random.uniform(0, jitter) # 중단 감지 해상도 0.5s (10초 주기에 1초 해상도는 과함) deadline = time.time() + sleep_sec while time.time() < deadline: if not self._running: return time.sleep(0.5) def _tick_all(self) -> None: from ..utils.universe_source import universe_source_active # 같은 seq 를 공유하는 전략이 있으면 REST 1회만 호출 (캐시) seq_cache: Dict[str, List[Dict]] = {} for cfg in self._configs: sid = cfg["strategy_id"] # UNIVERSE_SOURCE != condition 이면 KIS REST 스킵 (kiwoom_condition 등) if not universe_source_active(sid, "condition"): continue seq = cfg["seq"] if seq in seq_cache: rows = seq_cache[seq] else: try: rows = self.client.get_condition_result(self.user_id, seq) or [] except Exception as e: logger.debug("조건검색 결과 조회 실패 (%s/%s): %s", sid, seq, e) rows = [] seq_cache[seq] = rows self._apply_result(sid, rows) def _apply_result(self, strategy_id: str, rows: List[Dict]) -> None: """ 조건검색 raw 결과를 universe 에 반영. EXIT grace 정책 (``CONDITION_EXIT_GRACE_SEC`` > 0 일 때): - raw 결과에서 빠진 종목을 즉시 EXIT 처리하지 않고 ``_pending_exit`` 에 ``first_missing_at`` 시각과 함께 등록. - grace 초가 지나야 진짜 EXIT (universe 에서 제거) → WS 구독 해제 → 갭보정 리셋. - grace 중 다시 raw 에 등장하면 pending 에서 빼고 universe 에 그대로 keep (구독·캐시·봉 데이터 보존). 효과: 단발성 EXIT/RE-ENTER 폭주 흡수. 시장 노이즈로 1~2 tick 빠지는 케이스를 걸러 매수 시그널 평가용 RAM 데이터 (회복률·낙폭 등) 가 휘발 안 됨. """ raw_set = {r["code"] for r in rows if r.get("code")} new_names = { r["code"]: r.get("name", r["code"]) for r in rows if r.get("code") } now = time.time() grace = self._exit_grace_sec with self._lock: prev = self._current.get(strategy_id, set()) first_tick = strategy_id not in self._initialized pending = self._pending_exit.setdefault(strategy_id, {}) # ── grace 적용 ──────────────────────────────────────── kept_in_grace: Set[str] = set() if grace > 0: # 1) raw 에 다시 등장 → grace 해제 (회생) for c in raw_set & set(pending.keys()): pending.pop(c, None) # 2) raw 에서 빠진 prev 종목 → pending 등록 (처음 사라진 시각) for c in prev - raw_set: if c not in pending: pending[c] = now # 3) grace 미경과 종목 → universe 에 keep / 경과 → pending 제거 expired: Set[str] = set() for c, first_at in list(pending.items()): if now - first_at >= grace: expired.add(c) for c in expired: pending.pop(c, None) kept_in_grace = set(pending.keys()) # grace=0 (기존 동작) 이면 kept_in_grace = ∅, pending 안 쓰임. # 봇이 실제로 보는 effective universe (raw + grace keep) new_set = raw_set | kept_in_grace # ── 변동 계산 (effective 기준) ─────────────────────── enters = new_set - prev exits = prev - new_set self._current[strategy_id] = new_set self._current_order[strategy_id] = self._build_ordered_universe( rows=rows, new_set=new_set, prev_order=list(self._current_order.get(strategy_id) or []), ) self._initialized.add(strategy_id) for c, n in new_names.items(): self._name_map[c] = n changed = bool(enters or exits) n_grace = len(kept_in_grace) if changed: grace_tag = f", grace={n_grace}" if n_grace else "" logger.info( "🔄 [%s] +%d / -%d (현재 %d종목%s)", strategy_id, len(enters), len(exits), len(new_set), grace_tag, ) if enters: preview = ", ".join( f"{c}({new_names.get(c, c)})" for c in list(sorted(enters))[:5] ) logger.info(" ENTER: %s%s", preview, " …" if len(enters) > 5 else "") if exits: preview = ", ".join(sorted(exits)[:5]) logger.info(" EXIT : %s%s", preview, " …" if len(exits) > 5 else "") if self.on_change and changed: try: self.on_change(strategy_id, new_set, enters, exits) except Exception as e: logger.warning("on_change 콜백 예외: %s", e) # 백테스트 재현성 보장: 첫 tick 또는 변동 발생 tick 마다 풀 스냅샷 저장 # (effective universe 기준 — 봇이 실제로 보던 universe 가 그대로 기록됨) # 변동 없는 tick 은 공간 절약 위해 skip if changed or first_tick: with self._lock: ordered = list(self._current_order.get(strategy_id) or []) self._save_snapshot(strategy_id, ordered, new_names) @staticmethod def _build_ordered_universe( *, rows: List[Dict], new_set: Set[str], prev_order: List[str], ) -> List[str]: """ 실매 ``get_candidates_for`` / DB history 공통 순서. 1) HTS/KIS 조건검색 응답 순서 2) grace 유지(이전 순서) 3) 나머지 """ ordered: List[str] = [] seen: Set[str] = set() for r in rows: c = str(r.get("code") or "").strip() if c and c in new_set and c not in seen: seen.add(c) ordered.append(c) for c in prev_order: if c in new_set and c not in seen: seen.add(c) ordered.append(c) for c in new_set: if c not in seen: ordered.append(c) return ordered def _save_snapshot( self, strategy_id: str, codes_ordered: List[str], names: Dict[str, str], ) -> None: """변동이 감지된 tick 의 풀 유니버스 스냅샷 저장 (HTS 응답 순서 유지).""" if not (self.history_enabled and self.db is not None): return # 유니버스가 비어있어도 "비었다" 는 사실을 기록해야 백테스트에서 재현 가능. # 단, 한 번도 결과를 못 받은 상태(첫 호출 실패 등)는 저장 X. event_time = dt.now().strftime("%Y-%m-%d %H:%M:%S") items = [ {"code": c, "name": names.get(c, c)} for c in codes_ordered if c ] try: n = self.db.insert_condition_universe_snapshot( strategy_id=strategy_id, event_time=event_time, items=items, ) logger.debug( "📼 [history] %s @%s %d종목 저장", strategy_id, event_time, n, ) except Exception as e: logger.debug("history 저장 예외: %s", e)