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)
|
||||
|
||||
@@ -115,12 +115,18 @@ class WSManager:
|
||||
# (전략 쓰레드에서 subscribe() 시 동기 REST 호출하면 매수 체크가
|
||||
# 수 분간 블로킹됨 → 백그라운드 워커 큐로 이관)
|
||||
self._gap_q: "queue.Queue[str]" = queue.Queue(maxsize=1024)
|
||||
self._gap_prio_q: "queue.Queue[str]" = queue.Queue(maxsize=512)
|
||||
self._gap_mode: Dict[str, str] = {} # code → "1m" | "full"
|
||||
self._gap_filled: Set[str] = set() # 이미 갭보정 완료한 코드
|
||||
self._gap_inflight: Set[str] = set() # 큐에 등록/처리 중인 코드
|
||||
self._gap_retry_count: Dict[str, int] = {} # TF 실패 시 재시도 카운터
|
||||
self._gap_tf_ok: Dict[str, Set[int]] = {} # 종목별 성공한 TF (재시도 시 스킵)
|
||||
self._gap_lock = threading.Lock()
|
||||
self._gap_worker_thread: Optional[threading.Thread] = None
|
||||
self._gap_worker_threads: List[threading.Thread] = []
|
||||
self._gap_worker_boot_logged = False
|
||||
# 전체 재갭보정(재접속 시) 중복 트리거 방지
|
||||
self._bulk_refill_running = False
|
||||
self._bulk_refill_last_ts: float = 0.0
|
||||
# 키움 ka10001 유통/상장주식수 — 전략 공통 (stock_share_meta DB 동기)
|
||||
self._share_cache: Dict[str, Dict[str, int]] = {}
|
||||
self._share_q: "queue.Queue[str]" = queue.Queue(maxsize=1024)
|
||||
@@ -209,8 +215,9 @@ class WSManager:
|
||||
self.ws_cache.set_on_connected_callback(self._trigger_bulk_refill_async)
|
||||
|
||||
logger.info(
|
||||
"✅ WSManager 활성 (tfs=%s, permanent=%d, gap_worker=ON)",
|
||||
"✅ WSManager 활성 (tfs=%s, permanent=%d, gap_workers=%d)",
|
||||
tfs, len(self._permanent_codes),
|
||||
max(1, min(get_env_int("WS_GAP_FILL_WORKERS", 2), 4)),
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
@@ -308,7 +315,11 @@ class WSManager:
|
||||
if self._kiwoom_ws.subscribe(code):
|
||||
added_kw.append(code)
|
||||
for code in added_kw:
|
||||
self._enqueue_gap_fill(code)
|
||||
if code in self._permanent_codes:
|
||||
self._enqueue_gap_fill(code)
|
||||
else:
|
||||
# 후보 종목: 1M 우선 갭보정을 큐 앞쪽에 — BREAKOUT 매수체크 즉시 가능
|
||||
self._enqueue_gap_fill(code, priority=True, mode="1m")
|
||||
|
||||
for code in sorted(kis_want - kis_now):
|
||||
self.ws_cache.subscribe(code)
|
||||
@@ -522,40 +533,78 @@ class WSManager:
|
||||
# 내부: 갭 보정 — 백그라운드 워커 파이프라인
|
||||
# ------------------------------------------------------------------
|
||||
def _start_gap_worker(self) -> None:
|
||||
"""갭보정 전담 데몬 워커 스레드 기동 (단일 워커 → REST 레이트리밋 자연 직렬화)."""
|
||||
if self._gap_worker_thread and self._gap_worker_thread.is_alive():
|
||||
"""갭보정 백그라운드 워커 N개 기동 — 우선큐(후보 1M)와 일반큐 병렬 소진."""
|
||||
want = max(1, min(get_env_int("WS_GAP_FILL_WORKERS", 2), 4))
|
||||
alive = [t for t in self._gap_worker_threads if t.is_alive()]
|
||||
if len(alive) >= want:
|
||||
return
|
||||
t = threading.Thread(
|
||||
target=self._gap_worker_loop,
|
||||
name="WS-GapFillWorker",
|
||||
daemon=True,
|
||||
start_id = len(self._gap_worker_threads)
|
||||
for i in range(start_id, want):
|
||||
t = threading.Thread(
|
||||
target=self._gap_worker_loop,
|
||||
args=(i,),
|
||||
name=f"WS-GapFillWorker-{i}",
|
||||
daemon=True,
|
||||
)
|
||||
t.start()
|
||||
self._gap_worker_threads.append(t)
|
||||
logger.info(
|
||||
"✅ 갭보정 워커 %d개 시작 (queue 병렬, WS_GAP_FILL_WORKERS=%d)",
|
||||
want, want,
|
||||
)
|
||||
t.start()
|
||||
self._gap_worker_thread = t
|
||||
logger.info("✅ 갭보정 워커 스레드 시작 (queue 기반 비동기 처리)")
|
||||
|
||||
def _enqueue_gap_fill(self, code: str) -> None:
|
||||
def _enqueue_gap_fill(
|
||||
self,
|
||||
code: str,
|
||||
*,
|
||||
force: bool = False,
|
||||
priority: bool = False,
|
||||
mode: str = "full",
|
||||
) -> None:
|
||||
"""구독 직후 호출 — 갭보정 큐에 논블로킹 등록.
|
||||
|
||||
중복 방지:
|
||||
- 이미 완료(`_gap_filled`) → 스킵
|
||||
- 이미 완료(`_gap_filled`) → 스킵 (force=True 시 재시도)
|
||||
- 이미 큐/처리 중(`_gap_inflight`) → 스킵
|
||||
|
||||
Args:
|
||||
priority: True 이면 우선 큐(후보 종목 1M 웜업 등)
|
||||
mode: ``"1m"`` = 1분봉만 먼저, ``"full"`` = 설정된 전 TF
|
||||
"""
|
||||
if not code:
|
||||
return
|
||||
fill_mode = "1m" if str(mode).strip().lower() == "1m" else "full"
|
||||
with self._gap_lock:
|
||||
if code in self._gap_filled or code in self._gap_inflight:
|
||||
if code in self._gap_inflight:
|
||||
return
|
||||
if code in self._gap_filled and not force:
|
||||
return
|
||||
if force:
|
||||
self._gap_filled.discard(code)
|
||||
self._gap_inflight.add(code)
|
||||
self._gap_mode[code] = fill_mode
|
||||
target_q = self._gap_prio_q if priority else self._gap_q
|
||||
try:
|
||||
self._gap_q.put_nowait(code)
|
||||
target_q.put_nowait(code)
|
||||
except queue.Full:
|
||||
# 큐가 가득 차면 inflight 해제 후 포기 (WS 틱으로 자연 누적)
|
||||
with self._gap_lock:
|
||||
self._gap_inflight.discard(code)
|
||||
self._gap_mode.pop(code, None)
|
||||
logger.warning("⚠️ 갭보정 큐 full → %s 스킵 (WS 실시간 누적으로 대체)", code)
|
||||
self._enqueue_share_meta(code)
|
||||
|
||||
def _dequeue_gap_fill(self) -> tuple[Optional[str], bool]:
|
||||
"""우선 큐 → 일반 큐 순으로 (code, from_priority) 반환."""
|
||||
try:
|
||||
return self._gap_prio_q.get_nowait(), True
|
||||
except queue.Empty:
|
||||
pass
|
||||
try:
|
||||
return self._gap_q.get(timeout=1.0), False
|
||||
except queue.Empty:
|
||||
return None, False
|
||||
|
||||
def _start_share_meta_worker(self) -> None:
|
||||
"""유통주식수(ka10001) 전담 워커 — 장외에도 동작, 전략 공통."""
|
||||
if self._share_worker_thread and self._share_worker_thread.is_alive():
|
||||
@@ -661,6 +710,15 @@ class WSManager:
|
||||
return
|
||||
if self._bulk_refill_running:
|
||||
return
|
||||
debounce = float(get_env_int("WS_GAP_BULK_REFILL_DEBOUNCE_SEC", 120))
|
||||
now = time.time()
|
||||
if debounce > 0 and (now - self._bulk_refill_last_ts) < debounce:
|
||||
logger.debug(
|
||||
"🔄 [갭보정-전체] 스킵 (디바운스 %.0fs, 마지막 %.0fs 전)",
|
||||
debounce, now - self._bulk_refill_last_ts,
|
||||
)
|
||||
return
|
||||
self._bulk_refill_last_ts = now
|
||||
self._bulk_refill_running = True
|
||||
|
||||
def _bulk():
|
||||
@@ -673,6 +731,7 @@ class WSManager:
|
||||
# 재접속이므로 모든 종목 갭보정 재실행
|
||||
with self._gap_lock:
|
||||
self._gap_filled.clear()
|
||||
self._gap_tf_ok.clear()
|
||||
logger.info(
|
||||
"🔄 [갭보정-전체] WS 재접속 → %d종목 큐 재등록", len(codes),
|
||||
)
|
||||
@@ -683,59 +742,140 @@ class WSManager:
|
||||
|
||||
threading.Thread(target=_bulk, name="WS-BulkRefill", daemon=True).start()
|
||||
|
||||
def _gap_worker_loop(self) -> None:
|
||||
"""단일 워커 루프: 큐에서 코드 꺼내 순차 처리 → REST 레이트리밋 자연 완충."""
|
||||
# 크레덴셜은 첫 작업 시점에 1회 조회 후 캐시 (env 변경 무시하고 세션 유지)
|
||||
def _gap_worker_loop(self, worker_id: int = 0) -> None:
|
||||
"""워커 루프: 공유 큐에서 코드 꺼내 ka10080 갭보정 (N워커 병렬)."""
|
||||
kw_key = kw_secret = None
|
||||
kw_mock = False
|
||||
kw_resolved = False
|
||||
|
||||
while True:
|
||||
try:
|
||||
code = self._gap_q.get(timeout=1.0)
|
||||
except queue.Empty:
|
||||
code, from_prio = self._dequeue_gap_fill()
|
||||
if not code:
|
||||
continue
|
||||
|
||||
if code is None: # 종료 시그널
|
||||
return
|
||||
with self._gap_lock:
|
||||
gap_mode = self._gap_mode.get(code, "full")
|
||||
only_1m = gap_mode == "1m"
|
||||
|
||||
# 장중만 실행 (장외면 완료 마커 찍고 다음)
|
||||
if not self._is_market_hours() and not get_env_bool("WS_GAP_FILL_OFF_HOURS", False):
|
||||
with self._gap_lock:
|
||||
self._gap_inflight.discard(code)
|
||||
self._gap_mode.pop(code, None)
|
||||
self._gap_filled.add(code)
|
||||
self._gap_q.task_done()
|
||||
if from_prio:
|
||||
self._gap_prio_q.task_done()
|
||||
else:
|
||||
self._gap_q.task_done()
|
||||
continue
|
||||
|
||||
if not kw_resolved:
|
||||
if kw_key is None:
|
||||
kw_key, kw_secret, kw_mock = self._get_kiwoom_credentials()
|
||||
use_kiwoom = bool(kw_key and kw_secret and get_kiwoom_candles_df is not None)
|
||||
if use_kiwoom:
|
||||
kw_status = f"✅ ({'모의' if kw_mock else '실전'})"
|
||||
else:
|
||||
kw_status = "❌"
|
||||
logger.info(
|
||||
"🔧 [갭보정-워커] kiwoom=%s, KIS_fallback=%s",
|
||||
kw_status,
|
||||
"ON" if get_env_bool("WS_GAP_FILL_KIS_FALLBACK", False) else "OFF",
|
||||
)
|
||||
kw_resolved = True
|
||||
with self._gap_lock:
|
||||
if not self._gap_worker_boot_logged:
|
||||
use_kiwoom = bool(
|
||||
kw_key and kw_secret and get_kiwoom_candles_df is not None
|
||||
)
|
||||
if use_kiwoom:
|
||||
if get_env_bool("KIWOOM_WS_FORCE_REAL", True):
|
||||
kw_status = "✅ (실전·시세)"
|
||||
else:
|
||||
kw_status = f"✅ ({'모의' if kw_mock else '실전'})"
|
||||
else:
|
||||
kw_status = "❌"
|
||||
n_workers = max(
|
||||
1, min(get_env_int("WS_GAP_FILL_WORKERS", 2), 4),
|
||||
)
|
||||
logger.info(
|
||||
"🔧 [갭보정-워커×%d] kiwoom=%s, KIS_fallback=%s",
|
||||
n_workers,
|
||||
kw_status,
|
||||
"ON" if get_env_bool("WS_GAP_FILL_KIS_FALLBACK", False) else "OFF",
|
||||
)
|
||||
self._gap_worker_boot_logged = True
|
||||
|
||||
try:
|
||||
self._fill_gap_for_code(
|
||||
only_tfs = {1} if only_1m else None
|
||||
ok = self._fill_gap_for_code(
|
||||
code,
|
||||
kw_key=kw_key, kw_secret=kw_secret, kw_mock=kw_mock,
|
||||
only_tfs=only_tfs,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("갭보정 워커 예외 (%s): %s", code, e)
|
||||
ok = False
|
||||
finally:
|
||||
with self._gap_lock:
|
||||
self._gap_inflight.discard(code)
|
||||
self._gap_filled.add(code)
|
||||
self._gap_q.task_done()
|
||||
self._gap_mode.pop(code, None)
|
||||
|
||||
# 종목 간 짧은 sleep (REST 레이트리밋 완충)
|
||||
time.sleep(random.uniform(0.2, 0.4))
|
||||
if only_1m:
|
||||
# 1M 웜업 성공 → 나머지 TF 는 일반 큐로 이어서
|
||||
have_1m = 1 in self._gap_tf_ok.get(code, set())
|
||||
if have_1m:
|
||||
need = set(self.candle_agg.timeframes)
|
||||
have = self._gap_tf_ok.get(code, set())
|
||||
if not need.issubset(have):
|
||||
threading.Thread(
|
||||
target=lambda c=code: self._enqueue_gap_fill(
|
||||
c, mode="full",
|
||||
),
|
||||
daemon=True,
|
||||
).start()
|
||||
else:
|
||||
self._gap_filled.add(code)
|
||||
self._gap_retry_count.pop(code, None)
|
||||
else:
|
||||
retries = self._gap_retry_count.get(code, 0) + 1
|
||||
max_retries = get_env_int("WS_GAP_FILL_MAX_RETRIES", 3)
|
||||
self._gap_retry_count[code] = retries
|
||||
if retries < max_retries:
|
||||
delay = float(get_env_int("WS_GAP_FILL_RETRY_DELAY_SEC", 8))
|
||||
logger.warning(
|
||||
"⚠️ [갭보정] %s 1M 실패 → %ds 후 우선 재시도 (%d/%d)",
|
||||
code, int(delay), retries, max_retries,
|
||||
)
|
||||
threading.Timer(
|
||||
delay,
|
||||
lambda c=code: self._enqueue_gap_fill(
|
||||
c, force=True, priority=True, mode="1m",
|
||||
),
|
||||
).start()
|
||||
else:
|
||||
logger.warning(
|
||||
"⚠️ [갭보정] %s 1M 최대 재시도 초과 — WS 틱 누적으로 대체",
|
||||
code,
|
||||
)
|
||||
elif ok:
|
||||
self._gap_filled.add(code)
|
||||
self._gap_retry_count.pop(code, None)
|
||||
else:
|
||||
retries = self._gap_retry_count.get(code, 0) + 1
|
||||
max_retries = get_env_int("WS_GAP_FILL_MAX_RETRIES", 3)
|
||||
self._gap_retry_count[code] = retries
|
||||
if retries < max_retries:
|
||||
delay = float(get_env_int("WS_GAP_FILL_RETRY_DELAY_SEC", 8))
|
||||
logger.warning(
|
||||
"⚠️ [갭보정] %s 일부 TF 실패 → %ds 후 재시도 (%d/%d)",
|
||||
code, int(delay), retries, max_retries,
|
||||
)
|
||||
threading.Timer(
|
||||
delay,
|
||||
lambda c=code: self._enqueue_gap_fill(c, force=True),
|
||||
).start()
|
||||
else:
|
||||
logger.warning(
|
||||
"⚠️ [갭보정] %s 최대 재시도 초과 — WS 틱 누적으로 대체",
|
||||
code,
|
||||
)
|
||||
self._gap_filled.add(code)
|
||||
|
||||
if from_prio:
|
||||
self._gap_prio_q.task_done()
|
||||
else:
|
||||
self._gap_q.task_done()
|
||||
|
||||
# 종목 간 sleep (REST 레이트리밋 완충)
|
||||
self._gap_code_sleep()
|
||||
|
||||
@staticmethod
|
||||
def _is_market_hours() -> bool:
|
||||
@@ -753,20 +893,25 @@ class WSManager:
|
||||
|
||||
def _get_kiwoom_credentials(self):
|
||||
"""
|
||||
키움 분봉 갭보정용 키 조회 — **KIS 처럼 모의/실전 토글 가능**.
|
||||
키움 분봉 갭보정·유통주식수(ka10001)용 키 조회.
|
||||
|
||||
시세 REST 는 **매매 KIS_MOCK 과 분리** — WS·조건검색과 동일하게 실키 우선.
|
||||
|
||||
토글 결정 우선순위
|
||||
------------------
|
||||
1) ``KIWOOM_MOCK`` (있으면 단독 사용 — 키움만 별도 토글하고 싶을 때)
|
||||
2) 미지정 시 ``KIS_MOCK`` 폴백 (한 번만 설정해도 동기화)
|
||||
1) ``KIWOOM_WS_FORCE_REAL=true`` (기본) → **항상 실키·api.kiwoom.com**
|
||||
(KIS_MOCK·KIWOOM_MOCK 무시 — 갭보정/시세 전용)
|
||||
2) ``KIWOOM_WS_FORCE_REAL=false`` 일 때만:
|
||||
a) ``KIWOOM_MOCK`` 명시값
|
||||
b) 미지정 시 ``KIS_MOCK`` 폴백
|
||||
|
||||
키 슬롯 매핑
|
||||
-----------
|
||||
mock=True → ``KIWOOM_APP_KEY_MOCK`` → 없으면 ``KIWOOM_APP_KEY`` (레거시) 폴백
|
||||
mock=False → ``KIWOOM_APP_KEY_REAL`` → 없으면 ``KIWOOM_APP_KEY`` (레거시) 폴백
|
||||
|
||||
키 없으면 ``(None, None, is_mock)`` 반환 → 키움 비활성, KIS REST fallback 사용
|
||||
(``WS_GAP_FILL_KIS_FALLBACK`` 권장 ON)
|
||||
키 없으면 ``(None, None, is_mock)`` 반환 → 키움 비활성
|
||||
(``WS_GAP_FILL_KIS_FALLBACK`` ON 일 때만 KIS REST 폴백)
|
||||
|
||||
주의
|
||||
----
|
||||
@@ -780,14 +925,22 @@ class WSManager:
|
||||
if get_kiwoom_candles_df is None:
|
||||
return None, None, False
|
||||
try:
|
||||
# ── 1. 토글 결정 ──────────────────────────────────────────
|
||||
kw_mock_raw = (get_env_from_db("KIWOOM_MOCK", "") or "").strip().lower()
|
||||
if kw_mock_raw in ("true", "1", "yes", "y", "on"):
|
||||
is_mock = True
|
||||
elif kw_mock_raw in ("false", "0", "no", "n", "off"):
|
||||
# ── 1. 토글 결정 (시세 REST = WS 와 동일 정책) ───────────────
|
||||
force_real_str = (
|
||||
get_env_from_db("KIWOOM_WS_FORCE_REAL", "true") or "true"
|
||||
).strip().lower()
|
||||
force_real = force_real_str in ("true", "1", "yes", "y", "on")
|
||||
|
||||
if force_real:
|
||||
is_mock = False
|
||||
else:
|
||||
is_mock = get_env_bool("KIS_MOCK", True)
|
||||
kw_mock_raw = (get_env_from_db("KIWOOM_MOCK", "") or "").strip().lower()
|
||||
if kw_mock_raw in ("true", "1", "yes", "y", "on"):
|
||||
is_mock = True
|
||||
elif kw_mock_raw in ("false", "0", "no", "n", "off"):
|
||||
is_mock = False
|
||||
else:
|
||||
is_mock = get_env_bool("KIS_MOCK", True)
|
||||
|
||||
# ── 2. 키 슬롯 선택 (모의/실전) ───────────────────────────
|
||||
if is_mock:
|
||||
@@ -818,6 +971,63 @@ class WSManager:
|
||||
logger.debug("키움 크레덴셜 조회 예외: %s", e)
|
||||
return None, None, False
|
||||
|
||||
@staticmethod
|
||||
def _parse_tf_csv(raw: str, fallback: str) -> List[int]:
|
||||
"""콤마 구분 분봉 목록 파싱 (예: ``1,3`` → [1, 3])."""
|
||||
try:
|
||||
src = str(raw if str(raw or "").strip() else fallback)
|
||||
out = [int(x.strip()) for x in src.split(",") if x.strip()]
|
||||
return sorted(set(out))
|
||||
except Exception:
|
||||
return [int(x) for x in fallback.split(",")]
|
||||
|
||||
def _gap_priority_tfs(self) -> Set[int]:
|
||||
"""갭보정 1차 우선 TF — 기본 1M·3M (BREAKOUT·SHORT 핵심)."""
|
||||
raw = get_env_from_db("WS_GAP_FILL_PRIORITY_TFS", "1,3")
|
||||
return set(self._parse_tf_csv(raw, "1,3"))
|
||||
|
||||
def _resolve_gap_fill_tf_order(self) -> List[int]:
|
||||
"""우선 TF(1M·3M) 먼저, 이후 15M/60M — 레이트리밋 시 핵심 봉 선확보."""
|
||||
all_tfs = list(self.candle_agg.timeframes)
|
||||
priority = self._gap_priority_tfs()
|
||||
ordered: List[int] = [tf for tf in self._parse_tf_csv(
|
||||
get_env_from_db("WS_GAP_FILL_PRIORITY_TFS", "1,3"), "1,3",
|
||||
) if tf in all_tfs]
|
||||
for tf in all_tfs:
|
||||
if tf not in priority:
|
||||
ordered.append(tf)
|
||||
return ordered
|
||||
|
||||
def _gap_tf_sleep(self) -> None:
|
||||
"""TF 간 REST 호출 간격 — 키움 ka10080 레이트리밋 완충."""
|
||||
lo = float(get_env_float("WS_GAP_FILL_TF_SLEEP_MIN_SEC", 0.6))
|
||||
hi = float(get_env_float("WS_GAP_FILL_TF_SLEEP_MAX_SEC", 1.2))
|
||||
if hi < lo:
|
||||
lo, hi = hi, lo
|
||||
time.sleep(random.uniform(lo, hi))
|
||||
|
||||
def _gap_code_sleep(self) -> None:
|
||||
"""종목 간 REST 호출 간격."""
|
||||
lo = float(get_env_float("WS_GAP_FILL_CODE_SLEEP_MIN_SEC", 0.4))
|
||||
hi = float(get_env_float("WS_GAP_FILL_CODE_SLEEP_MAX_SEC", 0.8))
|
||||
if hi < lo:
|
||||
lo, hi = hi, lo
|
||||
time.sleep(random.uniform(lo, hi))
|
||||
|
||||
def _gap_tf_already_ok(self, code: str, tf: int) -> bool:
|
||||
with self._gap_lock:
|
||||
return tf in self._gap_tf_ok.get(code, set())
|
||||
|
||||
def _mark_gap_tf_ok(self, code: str, tf: int) -> None:
|
||||
with self._gap_lock:
|
||||
self._gap_tf_ok.setdefault(code, set()).add(tf)
|
||||
|
||||
def _all_gap_tfs_ok(self, code: str) -> bool:
|
||||
need = set(self.candle_agg.timeframes)
|
||||
with self._gap_lock:
|
||||
have = self._gap_tf_ok.get(code, set())
|
||||
return need.issubset(have)
|
||||
|
||||
def _fill_gap_for_code(
|
||||
self,
|
||||
code: str,
|
||||
@@ -825,28 +1035,53 @@ class WSManager:
|
||||
kw_key: Optional[str] = None,
|
||||
kw_secret: Optional[str] = None,
|
||||
kw_mock: bool = False,
|
||||
) -> None:
|
||||
only_tfs: Optional[Set[int]] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
단일 종목 갭 보정 — 워커 스레드 전용 (전략 쓰레드에서 직접 호출 금지).
|
||||
|
||||
정책 (기존 kis_scalping_ver2._fill_all_gaps 개선):
|
||||
[1] 키움 ka10080 우선 (1/3/15/60분봉 native + 과거봉 확보)
|
||||
[2] 키움 실패 시 KIS fallback — **기본 OFF** (`WS_GAP_FILL_KIS_FALLBACK=false`)
|
||||
→ KIS 모의 서버가 장중에도 HTTP 500 을 자주 반환해 로그 오염 + 백오프 지연.
|
||||
키움 있으면 굳이 안 쳐도 됨. WS 틱이 쌓여 자연 보완됨.
|
||||
→ env 로 true 지정 시에만 tf<=3 한정 KIS 호출.
|
||||
[3] 어느 경로든 실패 → WS 실시간 틱으로 자연 누적 (CandleAggregator)
|
||||
1M·3M 우선 → phase pause → 15M/60M 순.
|
||||
성공한 TF는 ``_gap_tf_ok`` 에 기록해 재시도 시 REST 중복 호출을 줄인다.
|
||||
|
||||
Returns:
|
||||
True if all configured timeframes got REST data; False if any TF empty.
|
||||
"""
|
||||
if not (self.ws_cache and self.candle_agg):
|
||||
return
|
||||
return False
|
||||
if not self._is_market_hours() and not get_env_bool("WS_GAP_FILL_OFF_HOURS", False):
|
||||
return
|
||||
return False
|
||||
|
||||
if self._all_gap_tfs_ok(code):
|
||||
return True
|
||||
|
||||
limit = get_env_int("WS_GAP_FILL_LIMIT", 120)
|
||||
use_kiwoom = bool(kw_key and kw_secret and get_kiwoom_candles_df is not None)
|
||||
kis_fallback_on = get_env_bool("WS_GAP_FILL_KIS_FALLBACK", False)
|
||||
priority = self._gap_priority_tfs()
|
||||
ordered_tfs = self._resolve_gap_fill_tf_order()
|
||||
if only_tfs is not None:
|
||||
ordered_tfs = [tf for tf in ordered_tfs if tf in only_tfs]
|
||||
phase_pause = float(get_env_float("WS_GAP_FILL_PHASE_PAUSE_SEC", 1.5))
|
||||
prev_tf: Optional[int] = None
|
||||
|
||||
for tf in ordered_tfs:
|
||||
if self._gap_tf_already_ok(code, tf):
|
||||
prev_tf = tf
|
||||
continue
|
||||
|
||||
# 우선(1M·3M) → 장기(15M·60M) 전환 전 추가 휴식
|
||||
if (
|
||||
prev_tf is not None
|
||||
and prev_tf in priority
|
||||
and tf not in priority
|
||||
and phase_pause > 0
|
||||
):
|
||||
logger.debug(
|
||||
"[갭보정] %s 우선TF 완료 → %ds pause 후 %dM",
|
||||
code, int(phase_pause), tf,
|
||||
)
|
||||
time.sleep(phase_pause)
|
||||
|
||||
for tf in self.candle_agg.timeframes:
|
||||
df = None
|
||||
|
||||
if use_kiwoom:
|
||||
@@ -856,7 +1091,7 @@ class WSManager:
|
||||
is_mock=kw_mock, n=limit,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("키움 갭보정 실패 (%s %dM): %s", code, tf, e)
|
||||
logger.warning("⚠️ [갭보정] 키움 실패 (%s %dM): %s", code, tf, e)
|
||||
|
||||
# KIS fallback — env 로 명시적 ON 일 때만 (1/3분봉 한정)
|
||||
if (df is None or df.empty) and kis_fallback_on and tf <= 3:
|
||||
@@ -869,9 +1104,16 @@ class WSManager:
|
||||
|
||||
if df is not None and not df.empty:
|
||||
self.candle_agg.fill_gap_from_rest(code, tf, df)
|
||||
self._mark_gap_tf_ok(code, tf)
|
||||
else:
|
||||
logger.warning("⚠️ [갭보정] %s %dM → REST 빈 응답 (재시도 대상)", code, tf)
|
||||
|
||||
# 같은 종목 내 타임프레임 전환 사이 짧은 sleep (차트 API 레이트리밋)
|
||||
time.sleep(random.uniform(0.15, 0.3))
|
||||
prev_tf = tf
|
||||
self._gap_tf_sleep()
|
||||
|
||||
return self._all_gap_tfs_ok(code) if only_tfs is None else (
|
||||
all(tf in self._gap_tf_ok.get(code, set()) for tf in only_tfs)
|
||||
)
|
||||
|
||||
def get_share_denom(self, code: str) -> float:
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user