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>
This commit is contained in:
431
kis_trader/network/kiwoom_condition_manager.py
Normal file
431
kis_trader/network/kiwoom_condition_manager.py
Normal file
@@ -0,0 +1,431 @@
|
||||
"""
|
||||
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 : 실시간 해제
|
||||
(이 경로는 ``_test_kiwoom_condition_realtime.py`` 에서 실계정으로 검증 완료)
|
||||
|
||||
설계 (최소 침습):
|
||||
* ``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 threading
|
||||
import time
|
||||
from typing import Dict, List, Optional, Set
|
||||
|
||||
from .condition_manager import ConditionSearchManager
|
||||
from ..utils.env import get_env_from_db, get_env_int
|
||||
from ..utils.logger import get_logger
|
||||
from ..ws.kis_ws import _get_kiwoom_token_cached
|
||||
|
||||
logger = get_logger("kis_trader.kwcond")
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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,
|
||||
):
|
||||
# 부모 초기화: 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 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
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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
|
||||
|
||||
try:
|
||||
import websocket # noqa: F401 (websocket-client 존재 확인)
|
||||
except Exception as e:
|
||||
logger.warning("websocket-client 미설치 → 키움 조건검색 비활성: %s", e)
|
||||
return False
|
||||
|
||||
self._token = _get_kiwoom_token_cached(
|
||||
self._app_key, self._app_secret, self._is_mock
|
||||
)
|
||||
if not self._token:
|
||||
logger.warning("키움 토큰 발급 실패 → 조건검색 매니저 비활성")
|
||||
return False
|
||||
|
||||
self._running = True
|
||||
self._ws_thread = threading.Thread(
|
||||
target=self._run_ws_forever, daemon=True, name="KwCondSearch"
|
||||
)
|
||||
self._ws_thread.start()
|
||||
|
||||
# 최초 CNSRLST 해결 + CNSRREQ 시도까지 잠깐 동기 대기 (KIS start() 가
|
||||
# seq 해결을 동기로 하는 것과 UX 정합). 타임아웃돼도 백그라운드는 계속 시도.
|
||||
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(
|
||||
"⚠️ 키움 조건검색 실시간 초기 등록 미완료(타임아웃) — 백그라운드 재시도 지속"
|
||||
)
|
||||
# 스레드는 떠 있으므로 True 반환 (등록은 비동기로 계속 시도/재접속).
|
||||
return True
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
try:
|
||||
if self._ws is not None:
|
||||
self._ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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":
|
||||
logger.debug("키움 조건검색 CNSRCLR 응답: rc=%s", data.get("return_code"))
|
||||
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 확정 + 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) 등록 — unique seq 별 1회. stex_tp=K (KRX)
|
||||
sent = 0
|
||||
for seq in self._active_seqs:
|
||||
try:
|
||||
ws.send(json.dumps({
|
||||
"trnm": "CNSRREQ",
|
||||
"seq": seq,
|
||||
"search_type": "1",
|
||||
"stex_tp": "K",
|
||||
}))
|
||||
sent += 1
|
||||
except Exception as e:
|
||||
logger.debug("키움 CNSRREQ 발송 실패 (seq=%s): %s", seq, e)
|
||||
|
||||
self._start_ok = sent > 0
|
||||
self._ready.set()
|
||||
|
||||
def _handle_cnsrreq(self, data: Dict) -> None:
|
||||
"""CNSRREQ 초기 응답: 현재 매칭 종목 리스트로 seq universe 초기화."""
|
||||
rc = str(data.get("return_code"))
|
||||
seq = str(data.get("seq") or "").strip()
|
||||
if rc not in ("0", "0.0"):
|
||||
logger.warning(
|
||||
"키움 CNSRREQ 실패 (seq=%s) rc=%s msg=%s",
|
||||
seq, rc, data.get("return_msg"),
|
||||
)
|
||||
return
|
||||
# seq 가 응답에 없을 수 있음 → active_seqs 가 1개면 그걸로 간주
|
||||
if not seq:
|
||||
with self._kw_lock:
|
||||
if len(self._active_seqs) == 1:
|
||||
seq = self._active_seqs[0]
|
||||
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._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()
|
||||
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()
|
||||
# seq 는 item 또는 상위에서 옴. item 에 없으면 유일 seq 로 폴백.
|
||||
seq = str(it.get("item") or it.get("seq") or "").strip() if isinstance(it, dict) else ""
|
||||
if not seq:
|
||||
if len(self._active_seqs) == 1:
|
||||
seq = self._active_seqs[0]
|
||||
else:
|
||||
# seq 불명 + 다중 조건 → 어느 유니버스인지 특정 불가, skip
|
||||
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
|
||||
touched.add(seq)
|
||||
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 와 동일하게 처리.
|
||||
"""
|
||||
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 = [{"code": c, "name": n} for c, n in bucket.items()]
|
||||
from ..utils.universe_source import universe_source_active
|
||||
for sid in sids:
|
||||
if 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)
|
||||
Reference in New Issue
Block a user