Changes: - Introduced new files for strategy definitions and study names. - Enhanced `backtest_web.py` with functions to handle integer display prices and trade data formatting. - Updated backtesting logic to incorporate end-of-day (EOD) parameters for breakout and momentum strategies. - Added EOD configuration options in the database and parameter search files. Impact: - These changes improve the modularity and usability of the backtesting framework, allowing for better integration of EOD strategies and clearer trade data presentation.
621 lines
26 KiB
Python
621 lines
26 KiB
Python
"""
|
|
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 random
|
|
import threading
|
|
import time
|
|
from typing import Any, Dict, List, Optional, Set
|
|
|
|
from .condition_manager import ConditionSearchManager
|
|
from ..utils.env import get_env_float, 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,
|
|
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
|
|
|
|
# ------------------------------------------------------------------
|
|
# 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("키움 토큰 발급 실패 → 조건검색 매니저 비활성")
|
|
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("키움 토큰 발급 실패 → 조건검색 매니저 비활성")
|
|
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
|
|
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 재접속마다 조건식 목록 재조회 → CNSRREQ 재등록."""
|
|
if not self._running:
|
|
return
|
|
self._ready.clear()
|
|
self._start_ok = False
|
|
try:
|
|
ws.send(json.dumps({"trnm": "CNSRLST"}))
|
|
logger.debug("키움 조건검색 [공유WS] LOGIN → CNSRLST")
|
|
except Exception as e:
|
|
logger.debug("키움 조건검색 CNSRLST 발송 실패: %s", e)
|
|
|
|
def _on_shared_trnm(self, ws, msg: dict) -> None:
|
|
trnm = msg.get("trnm")
|
|
if trnm == "CNSRLST":
|
|
self._handle_condition_list(ws, msg.get("data") or [])
|
|
elif trnm == "CNSRREQ":
|
|
self._handle_cnsrreq(msg)
|
|
elif trnm == "CNSRCLR":
|
|
logger.debug("키움 조건검색 CNSRCLR 응답: rc=%s", msg.get("return_code"))
|
|
|
|
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":
|
|
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) 등록 — seq 별 순차 발송 (레이트리밋·응답 누락 방지)
|
|
self._send_cnsrreq_all(ws)
|
|
|
|
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:
|
|
logger.warning("키움 CNSRREQ 발송 실패 (seq=%s): %s", seq, e)
|
|
return False
|
|
|
|
def _send_cnsrreq_all(self, ws) -> None:
|
|
"""active seq 목록을 간격 두고 순차 CNSRREQ — 미응답 seq 는 타이머로 재발송."""
|
|
with self._kw_lock:
|
|
seqs = list(self._active_seqs)
|
|
try:
|
|
seqs.sort(key=lambda x: int(x))
|
|
except Exception:
|
|
pass
|
|
|
|
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", 3)
|
|
retry_delay = float(get_env_float("KIWOOM_CNSRREQ_RETRY_SEC", 5.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
|
|
|
|
if self._cnsrreq_retry_timer:
|
|
try:
|
|
self._cnsrreq_retry_timer.cancel()
|
|
except Exception:
|
|
pass
|
|
self._cnsrreq_retry_timer = None
|
|
|
|
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,
|
|
)
|
|
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))
|
|
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_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._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()
|
|
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)
|