feat: Add new files and enhance backtesting functionality
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.
This commit is contained in:
@@ -35,12 +35,13 @@ kis_trader/network/kiwoom_condition_manager.py — 키움 조건검색 기반
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
from typing import Dict, List, Optional, Set
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
from .condition_manager import ConditionSearchManager
|
||||
from ..utils.env import get_env_from_db, get_env_int
|
||||
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
|
||||
|
||||
@@ -76,6 +77,7 @@ class KiwoomConditionSearchManager(ConditionSearchManager):
|
||||
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 등은 부모가 세팅.
|
||||
@@ -90,6 +92,10 @@ class KiwoomConditionSearchManager(ConditionSearchManager):
|
||||
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:
|
||||
@@ -123,6 +129,10 @@ class KiwoomConditionSearchManager(ConditionSearchManager):
|
||||
# 최초 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 폴링이므로 사용 안 함
|
||||
@@ -136,12 +146,51 @@ class KiwoomConditionSearchManager(ConditionSearchManager):
|
||||
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
|
||||
)
|
||||
@@ -155,8 +204,6 @@ class KiwoomConditionSearchManager(ConditionSearchManager):
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -170,17 +217,78 @@ class KiwoomConditionSearchManager(ConditionSearchManager):
|
||||
logger.warning(
|
||||
"⚠️ 키움 조건검색 실시간 초기 등록 미완료(타임아웃) — 백그라운드 재시도 지속"
|
||||
)
|
||||
# 스레드는 떠 있으므로 True 반환 (등록은 비동기로 계속 시도/재접속).
|
||||
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 라이프사이클
|
||||
# ------------------------------------------------------------------
|
||||
@@ -321,22 +429,101 @@ class KiwoomConditionSearchManager(ConditionSearchManager):
|
||||
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)
|
||||
# 실시간(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 seq in self._active_seqs:
|
||||
try:
|
||||
ws.send(json.dumps({
|
||||
"trnm": "CNSRREQ",
|
||||
"seq": seq,
|
||||
"search_type": "1",
|
||||
"stex_tp": "K",
|
||||
}))
|
||||
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
|
||||
except Exception as e:
|
||||
logger.debug("키움 CNSRREQ 발송 실패 (seq=%s): %s", seq, e)
|
||||
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 초기화."""
|
||||
@@ -363,6 +550,8 @@ class KiwoomConditionSearchManager(ConditionSearchManager):
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user