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 제거 븅신같은 초기설계 아예 제거 진입모드에 구멍메움 호가진입을 켜도 호가가 안들어올때 호가 안보고 그냥 사버림
448 lines
19 KiB
Python
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)
|