Files
kis_bot/kis_trader/network/condition_manager.py
Hwang 61c72a8a4c feat(tests): 신규 키움 웹소켓 조건검색 및 실시간 조건검색 테스트 추가
변경 사항
----
- _test_kiwoom_condition_list.py: 키움 웹소켓 조건검색 '목록조회' 기능을 단독으로 테스트하는 스크립트 추가
- _test_kiwoom_condition_realtime.py: 'momentum' 조건식을 실시간으로 등록하고 초기 매칭 종목 리스트 및 실시간 편입/이탈을 수신하는 테스트 스크립트 추가
- _verify_columnar_bitid.py, _verify_shared_e2e_breakout.py, _verify_shared_e2e.py: 공유 메모리 및 dict 간의 데이터 일관성을 검증하는 테스트 추가

영향
----
- 신규 테스트 스크립트 추가로 키움 웹소켓 API의 기능 검증 및 안정성을 높임
- 기존 기능에 대한 영향 없음

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 01:27:00 +09:00

447 lines
19 KiB
Python

"""
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)