변경 사항 ---- - _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>
447 lines
19 KiB
Python
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)
|