Files
kis_bot/kis_trader/network/condition_manager.py
Your Name 36a3e2b4a1 feat: Enhance trading system with new permanent subscription features and order book management
Changes:
- Added a new API endpoint for managing permanent subscriptions, allowing users to enable or disable subscriptions dynamically.
- Implemented a function to fill candle data from Kiwoom, ensuring that only relevant data is inserted into the database.
- Introduced a mechanism to handle master subscription states, improving the management of subscription statuses.
- Updated the database schema to include new fields for managing subscription states and order book filtering.

Impact:
- These enhancements improve the flexibility and reliability of the trading system, allowing for better management of subscriptions and order book data, while reducing the risk of data inconsistencies.

히스토리 align 제거 븅신같은 초기설계 아예 제거
진입모드에 구멍메움
호가진입을 켜도 호가가 안들어올때 호가 안보고 그냥 사버림
2026-08-15 23:01:14 +09:00

448 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.ffffff) 으로 풀 스냅샷 INSERT만.
같은 초 DELETE 바꿔치기 없음. 백테스트는 ``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: 빠진 종목을 N초간 universe 에 keep (0=즉시 pop = 순차 push/pop).
# 정합(실매=history)을 깨지 않으려면 history 도 effective(이 목록)을 저장해야 함.
# 기본 0. 켜도 history=effective 이면 슬롯정합은 유지됨(실매만 sticky).
self._exit_grace_sec = float(get_env_int("CONDITION_EXIT_GRACE_SEC", 0))
# strategy_id → {code: first_missing_at_epoch} — grace>0 일 때만 사용
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
# 상태 — 순서 리스트가 본체(순차 ENTER append / EXIT remove), Set 은 조회 캐시
self._current: Dict[str, Set[str]] = {} # strategy_id → code set
self._current_order: Dict[str, List[str]] = {} # strategy_id → push/pop 순차 목록
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 → 유니버스 순차 반영 (ENTER append / EXIT remove).
기본(``CONDITION_EXIT_GRACE_SEC=0``): raw 변동을 즉시 push/pop.
grace>0: EXIT 를 N초 지연(sticky). history 는 반드시 effective 목록을
저장해야 백테 history 재생이 실매 RAM 과 맞음 — 부모 ``_save_snapshot`` / LS override 경로 동일.
"""
raw_order: List[str] = []
raw_seen: Set[str] = set()
new_names: Dict[str, str] = {}
for r in rows or []:
c = str(r.get("code") or "").strip()
if not c or c in raw_seen:
continue
raw_seen.add(c)
raw_order.append(c)
new_names[c] = str(r.get("name") or c)
raw_set = set(raw_seen)
now = time.time()
grace = self._exit_grace_sec
with self._lock:
prev_order = list(self._current_order.get(strategy_id) or [])
prev = set(prev_order) if prev_order else set(self._current.get(strategy_id) or [])
first_tick = strategy_id not in self._initialized
pending = self._pending_exit.setdefault(strategy_id, {})
kept_in_grace: Set[str] = set()
if grace > 0:
for c in raw_set & set(pending.keys()):
pending.pop(c, None)
for c in prev - raw_set:
if c not in pending:
pending[c] = now
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())
else:
pending.clear()
new_set = raw_set | kept_in_grace
# 순차 목록: raw 응답 순서 유지 + grace 잔여(이전 순서) + 나머지
ordered = self._build_ordered_universe(
rows=[{"code": c, "name": new_names.get(c, c)} for c in raw_order],
new_set=new_set,
prev_order=prev_order,
)
enters = new_set - prev
exits = prev - new_set
# 신규 ENTER 를 맨 앞 — {SID}_CAND_LIMIT 하드캡이 오래된 저코드만
# 남기고 AFR/스냅 신규를 잘라먹던 sticky 를 막는다.
if enters:
head = [c for c in ordered if c in enters]
tail = [c for c in ordered if c not in enters]
ordered = head + tail
self._current_order[strategy_id] = ordered
self._current[strategy_id] = set(ordered)
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 ""
mode_tag = "push/pop" if grace <= 0 else "grace-sticky"
logger.info(
"🔄 [%s] +%d / -%d (현재 %d종목%s, %s)",
strategy_id, len(enters), len(exits), len(ordered), grace_tag, mode_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, set(ordered), enters, exits)
except Exception as e:
logger.warning("on_change 콜백 예외: %s", e)
# effective 순서 그대로 history (첫 tick 또는 변동 시)
if changed or first_tick:
with self._lock:
ordered_save = list(self._current_order.get(strategy_id) or [])
self._save_snapshot(strategy_id, ordered_save, 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.%f")
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)