feat: 새로운 안전 규칙 및 최적화 적용을 통한 트레이딩 시스템 개선
변경 사항 (Changes): 구문 오류(Syntax error) 및 토큰 낭비를 방지하기 위해 에이전트 쉘(Agent shell)과 파이썬 코드 스니펫에 다수의 신규 안전 규칙(Safety rules)을 추가함. 스키마 검증 및 적절한 SQL 포맷팅을 보장하기 위해 임시(Ad-hoc) 데이터베이스 쿼리 작성 가이드라인을 도입함. 코드 수정 후 UI 기능이 정상 작동하는지 확인하기 위해, 백테스트 웹 서비스 재시작 및 브라우저 검증에 대한 새로운 규칙을 구현함. 시스템 전반의 무결성(Integrity)을 유지하기 위해 실전 매매(Live trading), 웹 백테스팅, 파라미터 탐색(Parameter searches) 간의 일관성 검사(Consistency checks) 체계를 확립함. 기대 효과 (Impact): 이러한 개선 사항들은 트레이딩 시스템의 견고성(Robustness)과 신뢰성을 향상시키며, 에러 발생을 최소화하고 다양한 시스템 컴포넌트 간의 원활한 상호작용을 보장함.
This commit is contained in:
@@ -8,9 +8,14 @@ kis_trader/network/kiwoom_condition_manager.py — 키움 조건검색 기반
|
||||
- CNSRLST : 서버 저장 조건식 목록 (name → seq 해결)
|
||||
- CNSRREQ : 실시간 조건검색 등록 (search_type="1") → 초기 매칭 + 이후 REAL push
|
||||
- REAL : 편입(843="I") / 이탈(843="D"), 종목코드는 9001
|
||||
- CNSRCLR : 실시간 해제
|
||||
- CNSRCLR : 실시간 해제 (재LOGIN/재등록 전 선행 — Bye 후 유령등록·900003 방지)
|
||||
(이 경로는 ``_test_kiwoom_condition_realtime.py`` 에서 실계정으로 검증 완료)
|
||||
|
||||
재접속 복구:
|
||||
* 키움 WS ``Bye`` 후에도 계정에 조건검색이 남을 수 있음 → ``KIWOOM_CNSRCLR_BEFORE_REQ``
|
||||
(기본 true) 로 CNSRREQ 전에 CNSRCLR.
|
||||
* rc=900003 은 seq 당 1회만 CLR→REQ (무한 재시도 금지).
|
||||
|
||||
설계 (최소 침습):
|
||||
* ``ConditionSearchManager`` 를 **서브클래싱** 하여
|
||||
- 결과 반영 로직(``_apply_result``), 순서 계산(``_build_ordered_universe``),
|
||||
@@ -36,17 +41,21 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
from .condition_manager import ConditionSearchManager
|
||||
from ..utils.env import get_env_float, get_env_from_db, get_env_int
|
||||
from ..utils.env import get_env_bool, 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
|
||||
|
||||
logger = get_logger("kis_trader.kwcond")
|
||||
|
||||
# 키움 CNSRREQ rc=900003 — WS Bye 후에도 계정에 조건검색 등록이 남는 경우
|
||||
_CNSR_ALREADY_REGISTERED_RC = "900003"
|
||||
|
||||
|
||||
def _normalize_code(raw) -> str:
|
||||
"""키움 종목코드 정규화: 'A005930' → '005930' (KIS 6자리 코드계와 정합)."""
|
||||
@@ -59,6 +68,22 @@ def _normalize_code(raw) -> str:
|
||||
return c
|
||||
|
||||
|
||||
def _extract_cnsr_seq(data: Dict) -> str:
|
||||
"""CNSRREQ/CNSRCLR 응답에서 seq 추출.
|
||||
|
||||
실패 응답은 top-level ``seq`` 가 비고 ``return_msg`` 에만
|
||||
``...(seq=1)`` 형태로 오는 경우가 있음 (문서·실제 필드 불일치).
|
||||
"""
|
||||
seq = str(data.get("seq") or "").strip()
|
||||
if seq:
|
||||
return seq
|
||||
msg = str(data.get("return_msg") or "")
|
||||
m = re.search(r"seq\s*=\s*(\d+)", msg, flags=re.IGNORECASE)
|
||||
if m:
|
||||
return m.group(1).strip()
|
||||
return ""
|
||||
|
||||
|
||||
class KiwoomConditionSearchManager(ConditionSearchManager):
|
||||
"""키움 웹소켓 실시간 조건검색 매니저.
|
||||
|
||||
@@ -133,6 +158,10 @@ class KiwoomConditionSearchManager(ConditionSearchManager):
|
||||
self._cnsrreq_pending: Set[str] = set()
|
||||
self._cnsrreq_confirmed: Set[str] = set()
|
||||
self._cnsrreq_retry_timer: Optional[threading.Timer] = None
|
||||
# 900003(이미 등록) 에 대한 CNSRCLR→재요청은 seq당 사이클 1회만 (무한 재시도 금지)
|
||||
self._cnsr_dup_cleared: Set[str] = set()
|
||||
# CNSRCLR→REQ 파이프라인에서 send 대상 WS 캐시
|
||||
self._cnsr_send_ws: Any = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API (오버라이드) — 부모 start() 는 REST 폴링이므로 사용 안 함
|
||||
@@ -258,11 +287,20 @@ class KiwoomConditionSearchManager(ConditionSearchManager):
|
||||
self._shared_ws.send_json({"trnm": "CNSRLST"})
|
||||
|
||||
def _on_shared_login(self, ws) -> None:
|
||||
"""시세 WS 재접속마다 조건식 목록 재조회 → CNSRREQ 재등록."""
|
||||
"""시세 WS 재접속마다 조건식 목록 재조회 → (CLR 후) CNSRREQ 재등록.
|
||||
|
||||
키움은 새벽 등 WS ``Bye`` 후에도 계정 단위로 조건검색 등록이 남을 수 있음.
|
||||
재LOGIN 시에는 기존 pending 재시도 타이머를 끊고, CNSRLST→CLR→REQ 로 맞춘다.
|
||||
"""
|
||||
if not self._running:
|
||||
return
|
||||
self._cancel_cnsrreq_retry()
|
||||
self._ready.clear()
|
||||
self._start_ok = False
|
||||
with self._kw_lock:
|
||||
self._cnsr_dup_cleared.clear()
|
||||
self._cnsrreq_pending.clear()
|
||||
self._cnsrreq_confirmed.clear()
|
||||
try:
|
||||
ws.send(json.dumps({"trnm": "CNSRLST"}))
|
||||
logger.debug("키움 조건검색 [공유WS] LOGIN → CNSRLST")
|
||||
@@ -276,7 +314,12 @@ class KiwoomConditionSearchManager(ConditionSearchManager):
|
||||
elif trnm == "CNSRREQ":
|
||||
self._handle_cnsrreq(msg)
|
||||
elif trnm == "CNSRCLR":
|
||||
logger.debug("키움 조건검색 CNSRCLR 응답: rc=%s", msg.get("return_code"))
|
||||
logger.debug(
|
||||
"키움 조건검색 CNSRCLR 응답: seq=%s rc=%s msg=%s",
|
||||
_extract_cnsr_seq(msg),
|
||||
msg.get("return_code"),
|
||||
msg.get("return_msg"),
|
||||
)
|
||||
|
||||
def _on_shared_real(self, ws, msg: dict) -> None:
|
||||
"""조건검색 편입/이탈 REAL — 843 필드 있는 항목만 처리."""
|
||||
@@ -369,7 +412,12 @@ class KiwoomConditionSearchManager(ConditionSearchManager):
|
||||
return
|
||||
|
||||
if trnm == "CNSRCLR":
|
||||
logger.debug("키움 조건검색 CNSRCLR 응답: rc=%s", data.get("return_code"))
|
||||
logger.debug(
|
||||
"키움 조건검색 CNSRCLR 응답: seq=%s rc=%s msg=%s",
|
||||
_extract_cnsr_seq(data),
|
||||
data.get("return_code"),
|
||||
data.get("return_msg"),
|
||||
)
|
||||
return
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -387,7 +435,7 @@ class KiwoomConditionSearchManager(ConditionSearchManager):
|
||||
return "", ""
|
||||
|
||||
def _handle_condition_list(self, ws, rows: List) -> None:
|
||||
"""CNSRLST 응답으로 name→seq 해결 후, 전략별 seq 확정 + CNSRREQ 발송."""
|
||||
"""CNSRLST 응답으로 name→seq 해결 후, 전략별 seq 확정 + CLR→CNSRREQ."""
|
||||
name_to_seq: Dict[str, str] = {}
|
||||
for it in rows:
|
||||
seq, name = self._seq_name(it)
|
||||
@@ -429,9 +477,55 @@ class KiwoomConditionSearchManager(ConditionSearchManager):
|
||||
self._sid_by_seq = sid_by_seq
|
||||
self._active_seqs = list(sid_by_seq.keys())
|
||||
|
||||
# 실시간(search_type=1) 등록 — seq 별 순차 발송 (레이트리밋·응답 누락 방지)
|
||||
# 실시간(search_type=1) 등록 — 유령등록 방지로 CLR 후 순차 REQ
|
||||
self._send_cnsrreq_all(ws)
|
||||
|
||||
def _cancel_cnsrreq_retry(self) -> None:
|
||||
if self._cnsrreq_retry_timer:
|
||||
try:
|
||||
self._cnsrreq_retry_timer.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
self._cnsrreq_retry_timer = None
|
||||
|
||||
def _send_one_cnsrclr(self, ws, seq: str) -> bool:
|
||||
"""단일 seq CNSRCLR(실시간 해제) 발송 — Bye 후 유령 등록 제거용."""
|
||||
payload = {"trnm": "CNSRCLR", "seq": seq}
|
||||
try:
|
||||
if self._shared_mode and self._shared_ws:
|
||||
return bool(self._shared_ws.send_json(payload))
|
||||
target = ws if ws is not None else self._ws
|
||||
if target is None:
|
||||
return False
|
||||
target.send(json.dumps(payload))
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("키움 CNSRCLR 발송 실패 (seq=%s): %s", seq, e)
|
||||
return False
|
||||
|
||||
def _clear_active_seqs(self, ws, seqs: Optional[List[str]] = None) -> None:
|
||||
"""active(또는 주어진) seq 전부 CNSRCLR — 간격은 env (하드코딩 금지)."""
|
||||
if seqs is None:
|
||||
with self._kw_lock:
|
||||
seqs = list(self._active_seqs)
|
||||
else:
|
||||
seqs = list(seqs)
|
||||
try:
|
||||
seqs.sort(key=lambda x: int(x) if str(x).isdigit() else 0)
|
||||
except Exception:
|
||||
pass
|
||||
if not seqs:
|
||||
return
|
||||
gap_lo = float(get_env_float("KIWOOM_CNSRCLR_GAP_MIN_SEC", 0.3))
|
||||
gap_hi = float(get_env_float("KIWOOM_CNSRCLR_GAP_MAX_SEC", 0.6))
|
||||
if gap_hi < gap_lo:
|
||||
gap_lo, gap_hi = gap_hi, gap_lo
|
||||
logger.info("🧹 키움 조건검색 CNSRCLR 선행 (seq=%s)", seqs)
|
||||
for i, seq in enumerate(seqs):
|
||||
if i > 0:
|
||||
time.sleep(random.uniform(gap_lo, gap_hi))
|
||||
self._send_one_cnsrclr(ws, seq)
|
||||
|
||||
def _send_one_cnsrreq(self, ws, seq: str) -> bool:
|
||||
"""단일 seq CNSRREQ 발송."""
|
||||
payload = {
|
||||
@@ -450,14 +544,24 @@ class KiwoomConditionSearchManager(ConditionSearchManager):
|
||||
return False
|
||||
|
||||
def _send_cnsrreq_all(self, ws) -> None:
|
||||
"""active seq 목록을 간격 두고 순차 CNSRREQ — 미응답 seq 는 타이머로 재발송."""
|
||||
"""active seq: (옵션) CNSRCLR → settle → 순차 CNSRREQ. 미응답만 유한 재시도."""
|
||||
self._cancel_cnsrreq_retry()
|
||||
self._cnsr_send_ws = ws
|
||||
with self._kw_lock:
|
||||
seqs = list(self._active_seqs)
|
||||
self._cnsr_dup_cleared.clear()
|
||||
try:
|
||||
seqs.sort(key=lambda x: int(x))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Bye 후 유령 등록(900003) 방지: 재등록 전에 항상 클리어 (기본 ON)
|
||||
if get_env_bool("KIWOOM_CNSRCLR_BEFORE_REQ", True):
|
||||
self._clear_active_seqs(ws, seqs)
|
||||
settle = float(get_env_float("KIWOOM_CNSRCLR_SETTLE_SEC", 1.0))
|
||||
if settle > 0:
|
||||
time.sleep(settle)
|
||||
|
||||
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:
|
||||
@@ -480,7 +584,7 @@ class KiwoomConditionSearchManager(ConditionSearchManager):
|
||||
self._schedule_cnsrreq_retry(ws, attempt=1)
|
||||
|
||||
def _schedule_cnsrreq_retry(self, ws, *, attempt: int) -> None:
|
||||
"""CNSRREQ 응답이 안 온 seq 만 간격 두고 재발송."""
|
||||
"""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))
|
||||
@@ -488,12 +592,7 @@ class KiwoomConditionSearchManager(ConditionSearchManager):
|
||||
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
|
||||
self._cancel_cnsrreq_retry()
|
||||
|
||||
def _retry() -> None:
|
||||
if not self._running:
|
||||
@@ -507,9 +606,13 @@ class KiwoomConditionSearchManager(ConditionSearchManager):
|
||||
return
|
||||
if attempt > max_retries:
|
||||
logger.warning(
|
||||
"⚠️ 키움 CNSRREQ 미응답 seq=%s — 최대 재시도 초과",
|
||||
"⚠️ 키움 CNSRREQ 미응답 seq=%s — 최대 재시도 초과 (추가 재시도 없음)",
|
||||
missing,
|
||||
)
|
||||
# pending 비워 이후 타이머/재발송이 남지 않게 함
|
||||
with self._kw_lock:
|
||||
for s in missing:
|
||||
self._cnsrreq_pending.discard(s)
|
||||
return
|
||||
logger.warning(
|
||||
"⚠️ 키움 CNSRREQ 미응답 seq=%s → %.0fs 후 재발송 (%d/%d)",
|
||||
@@ -525,21 +628,63 @@ class KiwoomConditionSearchManager(ConditionSearchManager):
|
||||
self._cnsrreq_retry_timer.daemon = True
|
||||
self._cnsrreq_retry_timer.start()
|
||||
|
||||
def _handle_cnsr_already_registered(self, seq: str, data: Dict) -> None:
|
||||
"""rc=900003: seq당 1회만 CNSRCLR→CNSRREQ. 두 번째면 포기(무한 금지)."""
|
||||
with self._kw_lock:
|
||||
already = seq in self._cnsr_dup_cleared
|
||||
if not already:
|
||||
self._cnsr_dup_cleared.add(seq)
|
||||
if already:
|
||||
logger.warning(
|
||||
"⚠️ 키움 CNSRREQ 900003 재발 (seq=%s) — CLR 후에도 충돌, 추가 재시도 없음 msg=%s",
|
||||
seq, data.get("return_msg"),
|
||||
)
|
||||
with self._kw_lock:
|
||||
self._cnsrreq_pending.discard(seq)
|
||||
return
|
||||
|
||||
ws = self._cnsr_send_ws
|
||||
if ws is None and self._shared_mode:
|
||||
ws = self._shared_ws
|
||||
if ws is None:
|
||||
ws = self._ws
|
||||
logger.warning(
|
||||
"⚠️ 키움 CNSRREQ 이미등록(900003) seq=%s → CNSRCLR 1회 후 재요청",
|
||||
seq,
|
||||
)
|
||||
self._send_one_cnsrclr(ws, seq)
|
||||
settle = float(get_env_float("KIWOOM_CNSRCLR_SETTLE_SEC", 1.0))
|
||||
if settle > 0:
|
||||
time.sleep(settle)
|
||||
self._send_one_cnsrreq(ws, seq)
|
||||
|
||||
def _handle_cnsrreq(self, data: Dict) -> None:
|
||||
"""CNSRREQ 초기 응답: 현재 매칭 종목 리스트로 seq universe 초기화."""
|
||||
rc = str(data.get("return_code"))
|
||||
seq = str(data.get("seq") or "").strip()
|
||||
if rc not in ("0", "0.0"):
|
||||
logger.warning(
|
||||
"키움 CNSRREQ 실패 (seq=%s) rc=%s msg=%s",
|
||||
seq, rc, data.get("return_msg"),
|
||||
)
|
||||
return
|
||||
seq = _extract_cnsr_seq(data)
|
||||
# seq 가 응답에 없을 수 있음 → active_seqs 가 1개면 그걸로 간주
|
||||
if not seq:
|
||||
with self._kw_lock:
|
||||
if len(self._active_seqs) == 1:
|
||||
seq = self._active_seqs[0]
|
||||
if rc not in ("0", "0.0"):
|
||||
msg = str(data.get("return_msg") or "")
|
||||
is_dup = (
|
||||
rc == _CNSR_ALREADY_REGISTERED_RC
|
||||
or "이미 등록" in msg
|
||||
)
|
||||
logger.warning(
|
||||
"키움 CNSRREQ 실패 (seq=%s) rc=%s msg=%s",
|
||||
seq, rc, msg,
|
||||
)
|
||||
if is_dup and seq:
|
||||
self._handle_cnsr_already_registered(seq, data)
|
||||
return
|
||||
# 그 외 에러: pending 에서 제거 → '미응답'으로 오인·무한 재발송 방지
|
||||
if seq:
|
||||
with self._kw_lock:
|
||||
self._cnsrreq_pending.discard(seq)
|
||||
return
|
||||
if not seq:
|
||||
return
|
||||
|
||||
@@ -569,13 +714,20 @@ class KiwoomConditionSearchManager(ConditionSearchManager):
|
||||
if not code:
|
||||
continue
|
||||
ins_del = str(vals.get("843") or "").strip().upper()
|
||||
# seq 는 item 또는 상위에서 옴. item 에 없으면 유일 seq 로 폴백.
|
||||
seq = str(it.get("item") or it.get("seq") or "").strip() if isinstance(it, dict) else ""
|
||||
# 조건식 번호는 values.841. item 은 종목코드(9001 과 동일)인 경우가 많아 seq 로 쓰면 안 됨.
|
||||
seq = str(vals.get("841") or "").strip()
|
||||
if not seq and isinstance(it, dict):
|
||||
raw_item = str(it.get("seq") or "").strip()
|
||||
if raw_item and raw_item in self._sid_by_seq:
|
||||
seq = raw_item
|
||||
if not seq:
|
||||
if len(self._active_seqs) == 1:
|
||||
seq = self._active_seqs[0]
|
||||
else:
|
||||
# seq 불명 + 다중 조건 → 어느 유니버스인지 특정 불가, skip
|
||||
logger.debug(
|
||||
"키움 조건 REAL skip — seq 미식별 code=%s 843=%s active=%s",
|
||||
code, ins_del, self._active_seqs,
|
||||
)
|
||||
continue
|
||||
bucket = self._seq_codes.setdefault(seq, {})
|
||||
if ins_del == "D":
|
||||
@@ -609,7 +761,16 @@ class KiwoomConditionSearchManager(ConditionSearchManager):
|
||||
bucket = dict(self._seq_codes.get(seq, {}))
|
||||
sids = list(self._sid_by_seq.get(seq, []))
|
||||
# rows: 삽입순(HTS 응답 순서) 유지 → _build_ordered_universe 가 그대로 사용
|
||||
rows = [{"code": c, "name": n} for c, n in bucket.items()]
|
||||
rows = []
|
||||
for c, n in bucket.items():
|
||||
disp = str(n or c).strip() or c
|
||||
if disp == c and self.db is not None:
|
||||
try:
|
||||
from ..utils.stock_name import resolve_stock_display_name
|
||||
disp = resolve_stock_display_name(self.db, c, fallback=c)
|
||||
except Exception:
|
||||
disp = c
|
||||
rows.append({"code": c, "name": disp})
|
||||
from ..utils.universe_source import universe_source_active
|
||||
for sid in sids:
|
||||
if not universe_source_active(sid, "kiwoom_condition"):
|
||||
|
||||
@@ -107,6 +107,11 @@ class WSManager:
|
||||
self._owner_holdings: Dict[str, Set[str]] = defaultdict(set)
|
||||
# 영구 구독(시장방향 ETF 등)
|
||||
self._permanent_codes: Set[str] = set()
|
||||
self._permanent_reload_ts: float = 0.0
|
||||
# 후보/보유 이탈 후 키움 틱 구독 유지 (만료 epoch) — KIS 41 영구구독과 분리
|
||||
self._grace_until: Dict[str, float] = {}
|
||||
# grace 1회 소진 후 재연장 방지 (재진입 시 discard)
|
||||
self._grace_exhausted: Set[str] = set()
|
||||
self._lock = threading.Lock()
|
||||
# 갭보정 WS 재접속 시: split 모드면 KIS∪키움 관심 종목 전체
|
||||
self._gap_refill_codes: Set[str] = set()
|
||||
@@ -116,7 +121,7 @@ class WSManager:
|
||||
# 수 분간 블로킹됨 → 백그라운드 워커 큐로 이관)
|
||||
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_mode: Dict[str, str] = {} # code → "1m" | "3m" | "full"
|
||||
self._gap_filled: Set[str] = set() # 이미 갭보정 완료한 코드
|
||||
self._gap_inflight: Set[str] = set() # 큐에 등록/처리 중인 코드
|
||||
self._gap_retry_count: Dict[str, int] = {} # TF 실패 시 재시도 카운터
|
||||
@@ -217,7 +222,7 @@ class WSManager:
|
||||
logger.info(
|
||||
"✅ 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)),
|
||||
max(1, min(get_env_int("WS_GAP_FILL_WORKERS", 4), 4)),
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
@@ -277,6 +282,11 @@ class WSManager:
|
||||
"""KIS/키움 구독 집합을 후보·보유·영구 기준으로 재동기화."""
|
||||
if not (self._split_feed_active and self.ws_cache and self._kiwoom_ws):
|
||||
return
|
||||
# permanent_subscriptions 테이블 갱신 반영 (보유 해제 후에도 영구구독 틱 유지)
|
||||
now = time.time()
|
||||
if now - self._permanent_reload_ts >= 300.0:
|
||||
self._load_permanent_codes()
|
||||
self._permanent_reload_ts = now
|
||||
with self._lock:
|
||||
cand_u: Set[str] = set()
|
||||
for s in self._owner_candidates.values():
|
||||
@@ -289,6 +299,16 @@ class WSManager:
|
||||
kw_want = cand_u | hold_u | perm
|
||||
tick_to_agg = set(cand_u - hold_u)
|
||||
self._gap_refill_codes = set(kis_want) | set(kw_want)
|
||||
# 재진입 시 grace 재사용 가능하도록 소진 플래그 해제
|
||||
active_want = cand_u | hold_u | perm
|
||||
for code in active_want:
|
||||
self._grace_exhausted.discard(code)
|
||||
self._grace_until.pop(code, None)
|
||||
|
||||
# 이탈 후 틱 grace — 키움 구독만 연장 (KIS 41 슬롯 보호)
|
||||
grace_active = self._purge_and_get_grace_codes()
|
||||
if grace_active:
|
||||
kw_want = set(kw_want) | grace_active
|
||||
|
||||
try:
|
||||
self._kiwoom_ws.set_candle_tick_codes(tick_to_agg)
|
||||
@@ -307,6 +327,8 @@ class WSManager:
|
||||
|
||||
to_kw = sorted(kw_want - kw_now)
|
||||
if to_kw:
|
||||
# 한도 여유(headroom) 확보: grace 만료·오래된 것부터 해제 후 신규 REG
|
||||
self._ensure_kiwoom_headroom_for_new(len(to_kw), kw_want)
|
||||
try:
|
||||
added_kw = self._kiwoom_ws.subscribe_many(to_kw)
|
||||
except Exception:
|
||||
@@ -314,18 +336,25 @@ class WSManager:
|
||||
for code in to_kw:
|
||||
if self._kiwoom_ws.subscribe(code):
|
||||
added_kw.append(code)
|
||||
with self._lock:
|
||||
owner_cands = {
|
||||
str(owner): set(codes)
|
||||
for owner, codes in self._owner_candidates.items()
|
||||
}
|
||||
for code in added_kw:
|
||||
if code in self._permanent_codes:
|
||||
self._enqueue_gap_fill(code)
|
||||
else:
|
||||
# 후보 종목: 1M 우선 갭보정을 큐 앞쪽에 — BREAKOUT 매수체크 즉시 가능
|
||||
self._enqueue_gap_fill(code, priority=True, mode="1m")
|
||||
# 전 후보 1M 우선 — REST 1회 후 RAM 3M 롤업(꼬리 트리거 웜업)
|
||||
gap_mode = self._candidate_gap_fill_mode(code, owner_cands)
|
||||
self._enqueue_gap_fill(code, priority=True, mode=gap_mode)
|
||||
|
||||
for code in sorted(kis_want - kis_now):
|
||||
self.ws_cache.subscribe(code)
|
||||
self._enqueue_gap_fill(code)
|
||||
|
||||
for code in sorted(kis_now - kis_want):
|
||||
# KIS 는 grace 미적용 (영구+보유만) — 즉시 해제
|
||||
self.ws_cache.unsubscribe(code)
|
||||
if code not in kw_want and self.candle_agg:
|
||||
self.candle_agg.remove_code(code)
|
||||
@@ -333,16 +362,115 @@ class WSManager:
|
||||
with self._kiwoom_ws._sub_lock:
|
||||
kw_now2 = set(self._kiwoom_ws._subscribed)
|
||||
for code in sorted(kw_now2 - kw_want):
|
||||
# want 밖이면 grace 등록 또는 즉시 해제
|
||||
if self._note_leave_for_grace(code):
|
||||
continue
|
||||
self._kiwoom_ws.unsubscribe(code)
|
||||
if code not in kis_want and self.candle_agg:
|
||||
self.candle_agg.remove_code(code)
|
||||
if self.tick_recorder and code not in kw_want:
|
||||
with self._lock:
|
||||
is_perm = code in self._permanent_codes
|
||||
if self.tick_recorder and code not in kw_want and not is_perm:
|
||||
self.tick_recorder.remove_code(code)
|
||||
if self.trigger_snapshot_recorder and code not in kw_want:
|
||||
if self.trigger_snapshot_recorder and code not in kw_want and not is_perm:
|
||||
self.trigger_snapshot_recorder.remove_code(code)
|
||||
|
||||
self._sync_tick_record_codes()
|
||||
|
||||
def _ws_grace_sec(self) -> int:
|
||||
# 후보/보유 이탈 후 틱 조금 더 쌓기 — 길면 파람이 이탈 구간 기회에 과적합되기 쉬워 30초 기본
|
||||
return max(0, get_env_int("WS_TICK_GRACE_SEC", 30))
|
||||
|
||||
def _ws_grace_headroom(self) -> int:
|
||||
return max(0, get_env_int("WS_TICK_GRACE_HEADROOM", 5))
|
||||
|
||||
def _purge_and_get_grace_codes(self) -> Set[str]:
|
||||
"""만료 grace 제거 후 활성 코드 집합 반환."""
|
||||
now = time.time()
|
||||
with self._lock:
|
||||
dead = [c for c, exp in self._grace_until.items() if exp <= now]
|
||||
for c in dead:
|
||||
self._grace_until.pop(c, None)
|
||||
self._grace_exhausted.add(c)
|
||||
return set(self._grace_until.keys())
|
||||
|
||||
def _note_leave_for_grace(self, code: str) -> bool:
|
||||
"""이탈 종목을 grace 기간 구독 유지. True=지금은 unsubscribe 하지 않음."""
|
||||
grace = self._ws_grace_sec()
|
||||
if grace <= 0 or not code:
|
||||
return False
|
||||
with self._lock:
|
||||
if code in self._permanent_codes:
|
||||
return False
|
||||
if code in self._grace_exhausted:
|
||||
return False
|
||||
# 이미 grace 중이면 유지 (만료 전 재등록으로 타이머 리셋 금지)
|
||||
if code in self._grace_until:
|
||||
return True
|
||||
for s in self._owner_holdings.values():
|
||||
if code in s:
|
||||
return False
|
||||
for s in self._owner_candidates.values():
|
||||
if code in s:
|
||||
return False
|
||||
self._grace_until[code] = time.time() + float(grace)
|
||||
return True
|
||||
|
||||
def _ensure_kiwoom_headroom_for_new(self, need: int, kw_want: Set[str]) -> None:
|
||||
"""신규 구독 전 grace 슬롯을 비워 키움 한도(기본 100)−headroom 을 확보."""
|
||||
if need <= 0 or not self._kiwoom_ws:
|
||||
return
|
||||
try:
|
||||
limit = int(get_env_int("KIWOOM_WS_MAX_SUBSCRIPTIONS", 100))
|
||||
except Exception:
|
||||
limit = 100
|
||||
headroom = self._ws_grace_headroom()
|
||||
soft_cap = max(1, limit - headroom)
|
||||
with self._kiwoom_ws._sub_lock:
|
||||
n_now = len(self._kiwoom_ws._subscribed)
|
||||
free = soft_cap - n_now
|
||||
if free >= need:
|
||||
return
|
||||
drop_n = need - max(0, free)
|
||||
with self._lock:
|
||||
grace_items = sorted(self._grace_until.items(), key=lambda x: x[1])
|
||||
dropped = 0
|
||||
for code, _exp in grace_items:
|
||||
if dropped >= drop_n:
|
||||
break
|
||||
if code in kw_want:
|
||||
continue
|
||||
with self._lock:
|
||||
self._grace_until.pop(code, None)
|
||||
self._grace_exhausted.add(code)
|
||||
try:
|
||||
self._kiwoom_ws.unsubscribe(code)
|
||||
except Exception:
|
||||
pass
|
||||
if self.tick_recorder:
|
||||
try:
|
||||
self.tick_recorder.remove_code(code)
|
||||
except Exception:
|
||||
pass
|
||||
dropped += 1
|
||||
|
||||
def _active_ws_subscribed_codes(self) -> Set[str]:
|
||||
"""현재 KIS·키움 WS 에 실제 구독 중인 종목 (틱 수신 가능 집합)."""
|
||||
out: Set[str] = set()
|
||||
try:
|
||||
if self._kiwoom_ws:
|
||||
with self._kiwoom_ws._sub_lock:
|
||||
out |= set(self._kiwoom_ws._subscribed)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if self.ws_cache:
|
||||
with self.ws_cache._sub_lock:
|
||||
out |= set(self.ws_cache._subscribed)
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
def _sync_tick_record_codes(self) -> None:
|
||||
"""``WS_TICK_RECORD_SCOPE`` 에 따라 TickRecorder 저장 대상 종목 갱신."""
|
||||
if not self.tick_recorder:
|
||||
@@ -358,6 +486,8 @@ class WSManager:
|
||||
for s in self._owner_holdings.values():
|
||||
hold_u |= s
|
||||
subscribed = perm | cand_u | hold_u
|
||||
# WS 구독이 아직 유지되는 종목(영구·후보 이탈 직후 등)도 틱 저장 대상에 포함
|
||||
subscribed |= self._active_ws_subscribed_codes()
|
||||
if scope in ("subscribed", "all", "full"):
|
||||
want = subscribed
|
||||
else:
|
||||
@@ -365,7 +495,7 @@ class WSManager:
|
||||
# 매수 후 종목이 후보 유니버스에서 이탈하면 보유 구간 틱이 끊겨
|
||||
# 백테 '틱청산' 재현이 불가(진입틱만 있고 청산틱 없음)해진다.
|
||||
# 실 체결(손절/익절) 정합을 위해 보유분 틱은 반드시 수집한다.
|
||||
want = cand_u | perm | hold_u
|
||||
want = cand_u | perm | hold_u | (subscribed - cand_u - hold_u)
|
||||
else:
|
||||
subscribed = set(perm)
|
||||
for refs in self._code_refs.values():
|
||||
@@ -534,7 +664,7 @@ class WSManager:
|
||||
# ------------------------------------------------------------------
|
||||
def _start_gap_worker(self) -> None:
|
||||
"""갭보정 백그라운드 워커 N개 기동 — 우선큐(후보 1M)와 일반큐 병렬 소진."""
|
||||
want = max(1, min(get_env_int("WS_GAP_FILL_WORKERS", 2), 4))
|
||||
want = max(1, min(get_env_int("WS_GAP_FILL_WORKERS", 4), 4))
|
||||
alive = [t for t in self._gap_worker_threads if t.is_alive()]
|
||||
if len(alive) >= want:
|
||||
return
|
||||
@@ -553,6 +683,29 @@ class WSManager:
|
||||
want, want,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _candidate_gap_fill_mode(
|
||||
code: str,
|
||||
owner_candidates: Optional[Dict[str, Set[str]]] = None,
|
||||
) -> str:
|
||||
"""후보 종목 갭보정 1차 TF — 기본 1M (3M은 REST 생략·1M 롤업).
|
||||
|
||||
``WS_GAP_FILL_CANDIDATE_MODE`` = ``1m``(기본) | ``3m`` | ``legacy``
|
||||
(legacy: SHORT만 3M 우선 — 구동작).
|
||||
"""
|
||||
mode = (
|
||||
get_env_from_db("WS_GAP_FILL_CANDIDATE_MODE", "1m") or "1m"
|
||||
).strip().lower()
|
||||
if mode in ("3m", "3"):
|
||||
return "3m"
|
||||
if mode in ("legacy", "short_3m"):
|
||||
owners = owner_candidates or {}
|
||||
short_codes = owners.get("SHORT") or set()
|
||||
if code in short_codes:
|
||||
return "3m"
|
||||
return "1m"
|
||||
return "1m"
|
||||
|
||||
def _enqueue_gap_fill(
|
||||
self,
|
||||
code: str,
|
||||
@@ -568,12 +721,13 @@ class WSManager:
|
||||
- 이미 큐/처리 중(`_gap_inflight`) → 스킵
|
||||
|
||||
Args:
|
||||
priority: True 이면 우선 큐(후보 종목 1M 웜업 등)
|
||||
mode: ``"1m"`` = 1분봉만 먼저, ``"full"`` = 설정된 전 TF
|
||||
priority: True 이면 우선 큐(후보 종목 1M/3M 웜업 등)
|
||||
mode: ``"1m"`` / ``"3m"`` = 해당 TF만 먼저, ``"full"`` = 설정된 전 TF
|
||||
"""
|
||||
if not code:
|
||||
return
|
||||
fill_mode = "1m" if str(mode).strip().lower() == "1m" else "full"
|
||||
mode_key = str(mode).strip().lower()
|
||||
fill_mode = mode_key if mode_key in ("1m", "3m") else "full"
|
||||
with self._gap_lock:
|
||||
if code in self._gap_inflight:
|
||||
return
|
||||
@@ -754,7 +908,9 @@ class WSManager:
|
||||
|
||||
with self._gap_lock:
|
||||
gap_mode = self._gap_mode.get(code, "full")
|
||||
only_1m = gap_mode == "1m"
|
||||
partial_tf: Optional[int] = (
|
||||
1 if gap_mode == "1m" else (3 if gap_mode == "3m" else None)
|
||||
)
|
||||
|
||||
# 장중만 실행 (장외면 완료 마커 찍고 다음)
|
||||
if not self._is_market_hours() and not get_env_bool("WS_GAP_FILL_OFF_HOURS", False):
|
||||
@@ -783,7 +939,7 @@ class WSManager:
|
||||
else:
|
||||
kw_status = "❌"
|
||||
n_workers = max(
|
||||
1, min(get_env_int("WS_GAP_FILL_WORKERS", 2), 4),
|
||||
1, min(get_env_int("WS_GAP_FILL_WORKERS", 4), 4),
|
||||
)
|
||||
logger.info(
|
||||
"🔧 [갭보정-워커×%d] kiwoom=%s, KIS_fallback=%s",
|
||||
@@ -794,12 +950,15 @@ class WSManager:
|
||||
self._gap_worker_boot_logged = True
|
||||
|
||||
try:
|
||||
only_tfs = {1} if only_1m else None
|
||||
only_tfs = {partial_tf} if partial_tf is not None 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,
|
||||
)
|
||||
# lock 밖 — 1M 성공 시 3M 롤업 (_gap_lock 비재진입)
|
||||
if partial_tf == 1:
|
||||
self._maybe_rollup_3m_from_1m(code)
|
||||
except Exception as e:
|
||||
logger.debug("갭보정 워커 예외 (%s): %s", code, e)
|
||||
ok = False
|
||||
@@ -808,10 +967,10 @@ class WSManager:
|
||||
self._gap_inflight.discard(code)
|
||||
self._gap_mode.pop(code, None)
|
||||
|
||||
if only_1m:
|
||||
# 1M 웜업 성공 → 나머지 TF 는 일반 큐로 이어서
|
||||
have_1m = 1 in self._gap_tf_ok.get(code, set())
|
||||
if have_1m:
|
||||
if partial_tf is not None:
|
||||
# 1M/3M 웜업 성공 → 나머지 TF 는 일반 큐로 이어서
|
||||
have_primary = partial_tf in self._gap_tf_ok.get(code, set())
|
||||
if have_primary:
|
||||
need = set(self.candle_agg.timeframes)
|
||||
have = self._gap_tf_ok.get(code, set())
|
||||
if not need.issubset(have):
|
||||
@@ -828,23 +987,25 @@ class WSManager:
|
||||
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
|
||||
retry_mode = f"{partial_tf}m"
|
||||
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,
|
||||
"⚠️ [갭보정] %s %dM 실패 → %ds 후 우선 재시도 (%d/%d)",
|
||||
code, partial_tf, int(delay), retries, max_retries,
|
||||
)
|
||||
threading.Timer(
|
||||
delay,
|
||||
lambda c=code: self._enqueue_gap_fill(
|
||||
c, force=True, priority=True, mode="1m",
|
||||
lambda c=code, m=retry_mode: self._enqueue_gap_fill(
|
||||
c, force=True, priority=True, mode=m,
|
||||
),
|
||||
).start()
|
||||
else:
|
||||
logger.warning(
|
||||
"⚠️ [갭보정] %s 1M 최대 재시도 초과 — WS 틱 누적으로 대체",
|
||||
code,
|
||||
"⚠️ [갭보정] %s %dM 최대 재시도 초과 — WS 틱 누적으로 대체",
|
||||
code, partial_tf,
|
||||
)
|
||||
self._gap_filled.add(code)
|
||||
elif ok:
|
||||
self._gap_filled.add(code)
|
||||
self._gap_retry_count.pop(code, None)
|
||||
@@ -982,16 +1143,16 @@ class WSManager:
|
||||
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"))
|
||||
"""갭보정 1차 우선 TF — 기본 1M (3M은 1M 롤업)."""
|
||||
raw = get_env_from_db("WS_GAP_FILL_PRIORITY_TFS", "1")
|
||||
return set(self._parse_tf_csv(raw, "1"))
|
||||
|
||||
def _resolve_gap_fill_tf_order(self) -> List[int]:
|
||||
"""우선 TF(1M·3M) 먼저, 이후 15M/60M — 레이트리밋 시 핵심 봉 선확보."""
|
||||
"""우선 TF(1M) 먼저, 이후 15M/60M — 3M REST는 롤업 시 스킵."""
|
||||
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",
|
||||
get_env_from_db("WS_GAP_FILL_PRIORITY_TFS", "1"), "1",
|
||||
) if tf in all_tfs]
|
||||
for tf in all_tfs:
|
||||
if tf not in priority:
|
||||
@@ -1014,9 +1175,98 @@ class WSManager:
|
||||
lo, hi = hi, lo
|
||||
time.sleep(random.uniform(lo, hi))
|
||||
|
||||
def _gap_fill_limit_for_tf(self, tf: int, code: Optional[str] = None) -> int:
|
||||
"""TF별 갭보정 REST 조회량 — 1M: SHORT 150 / MOMENTUM 등 500."""
|
||||
base = get_env_int("WS_GAP_FILL_LIMIT", 120)
|
||||
if tf == 1:
|
||||
short_lim = get_env_int("SHORT_GAP_FILL_LIMIT", 150)
|
||||
mom_lim = get_env_int("MOMENTUM_GAP_FILL_LIMIT", 500)
|
||||
need_deep = True
|
||||
if code:
|
||||
with self._lock:
|
||||
owners = {
|
||||
str(o): set(cs) for o, cs in self._owner_candidates.items()
|
||||
}
|
||||
deep_owners = set()
|
||||
for oid in ("MOMENTUM", "BREAKOUT", "SCALP", "RANGE_BREAK"):
|
||||
deep_owners |= owners.get(oid) or set()
|
||||
short_only = (
|
||||
code in (owners.get("SHORT") or set())
|
||||
and code not in deep_owners
|
||||
)
|
||||
need_deep = not short_only
|
||||
base = max(base, short_lim)
|
||||
if need_deep:
|
||||
base = max(base, mom_lim)
|
||||
if tf == 3:
|
||||
# 레거시 3M REST 경로 (롤업 OFF·legacy 모드)
|
||||
base = max(base, get_env_int("SHORT_GAP_FILL_LIMIT", 150))
|
||||
return base
|
||||
|
||||
def _code_needs_deep_1m(self, code: str) -> bool:
|
||||
"""모멘텀·돌파 등 1M 심층(500)이 필요한 종목인지."""
|
||||
with self._lock:
|
||||
owners = {
|
||||
str(o): set(cs) for o, cs in self._owner_candidates.items()
|
||||
}
|
||||
deep_owners: Set[str] = set()
|
||||
for oid in ("MOMENTUM", "BREAKOUT", "SCALP", "RANGE_BREAK"):
|
||||
deep_owners |= owners.get(oid) or set()
|
||||
return code in deep_owners
|
||||
|
||||
def _maybe_rollup_3m_from_1m(self, code: str) -> bool:
|
||||
"""1M RAM → 3M 롤업. 성공 시 _gap_tf_ok 에 3 마킹. 반환: 3M 준비 여부.
|
||||
|
||||
주의: ``_gap_tf_already_ok(1)`` 을 쓰면 SHORT 150봉이 모멘텀 500 미달로
|
||||
1M ok 가 지워져 롤업이 스킵된다 → 여기서는 RAM 1M 존재 여부만 본다.
|
||||
"""
|
||||
if not get_env_bool("WS_GAP_ROLLUP_3M_FROM_1M", True):
|
||||
return False
|
||||
if not self.candle_agg or 3 not in getattr(self.candle_agg, "timeframes", [1, 3]):
|
||||
with self._gap_lock:
|
||||
return 3 in self._gap_tf_ok.get(code, set())
|
||||
have_1m = 0
|
||||
try:
|
||||
have_1m = int(self.candle_agg.get_confirmed_count(code, 1) or 0)
|
||||
except Exception:
|
||||
have_1m = 0
|
||||
with self._gap_lock:
|
||||
marked_1 = 1 in self._gap_tf_ok.get(code, set())
|
||||
if have_1m <= 0 and not marked_1:
|
||||
with self._gap_lock:
|
||||
return 3 in self._gap_tf_ok.get(code, set())
|
||||
try:
|
||||
n = int(self.candle_agg.rollup_tf_from_1m(code, 3) or 0)
|
||||
have = self.candle_agg.get_confirmed_count(code, 3)
|
||||
if have >= 1 or n > 0:
|
||||
self._mark_gap_tf_ok(code, 3)
|
||||
if n > 0:
|
||||
logger.info(
|
||||
"✅ [갭보정-롤업] %s 1M→3M %d봉 보강 (확정=%d)",
|
||||
code, n, have,
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("⚠️ [갭보정-롤업] %s 1M→3M 실패: %s", code, e)
|
||||
with self._gap_lock:
|
||||
return 3 in self._gap_tf_ok.get(code, set())
|
||||
|
||||
def _momentum_min_candles(self) -> int:
|
||||
return max(50, get_env_int("MOMENTUM_LIVE_MIN_CANDLES", 500))
|
||||
|
||||
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())
|
||||
if tf not in self._gap_tf_ok.get(code, set()):
|
||||
return False
|
||||
# 1M 심층: 모멘텀 등만 500봉 미달 시 ok 해제(재갭). SHORT-only 150은 유지.
|
||||
if tf == 1 and self.candle_agg and self._code_needs_deep_1m(code):
|
||||
need = self._momentum_min_candles()
|
||||
have = self.candle_agg.get_confirmed_count(code, 1)
|
||||
if have < need:
|
||||
with self._gap_lock:
|
||||
self._gap_tf_ok.get(code, set()).discard(1)
|
||||
return False
|
||||
return True
|
||||
|
||||
def _mark_gap_tf_ok(self, code: str, tf: int) -> None:
|
||||
with self._gap_lock:
|
||||
@@ -1054,7 +1304,6 @@ class WSManager:
|
||||
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()
|
||||
@@ -1069,6 +1318,26 @@ class WSManager:
|
||||
prev_tf = tf
|
||||
continue
|
||||
|
||||
# 1M→3M 롤업 ON: 3M REST 생략 (키움 1회·구멍 방지)
|
||||
if (
|
||||
tf == 3
|
||||
and get_env_bool("WS_GAP_ROLLUP_3M_FROM_1M", True)
|
||||
and self._gap_tf_already_ok(code, 1)
|
||||
):
|
||||
self._maybe_rollup_3m_from_1m(code)
|
||||
prev_tf = tf
|
||||
continue
|
||||
if (
|
||||
tf == 3
|
||||
and get_env_bool("WS_GAP_ROLLUP_3M_FROM_1M", True)
|
||||
and not self._gap_tf_already_ok(code, 1)
|
||||
and (only_tfs is None or 1 in only_tfs or 3 in only_tfs)
|
||||
):
|
||||
# 1M 미확보 시 3M REST 대신 1M 먼저 (only_tfs에 1 없으면 스킵)
|
||||
if only_tfs is not None and 1 not in only_tfs:
|
||||
prev_tf = tf
|
||||
continue
|
||||
|
||||
# 우선(1M·3M) → 장기(15M·60M) 전환 전 추가 휴식
|
||||
if (
|
||||
prev_tf is not None
|
||||
@@ -1083,12 +1352,13 @@ class WSManager:
|
||||
time.sleep(phase_pause)
|
||||
|
||||
df = None
|
||||
tf_limit = self._gap_fill_limit_for_tf(tf, code=code)
|
||||
|
||||
if use_kiwoom:
|
||||
try:
|
||||
df = get_kiwoom_candles_df(
|
||||
code, tf, kw_key, kw_secret,
|
||||
is_mock=kw_mock, n=limit,
|
||||
is_mock=kw_mock, n=tf_limit,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("⚠️ [갭보정] 키움 실패 (%s %dM): %s", code, tf, e)
|
||||
@@ -1097,7 +1367,7 @@ class WSManager:
|
||||
if (df is None or df.empty) and kis_fallback_on and tf <= 3:
|
||||
try:
|
||||
df = self.kis_client.get_minute_chart(
|
||||
code, period=str(tf), limit=limit,
|
||||
code, period=str(tf), limit=tf_limit,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("KIS 갭보정 실패 (%s %dM): %s", code, tf, e)
|
||||
@@ -1105,14 +1375,24 @@ 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)
|
||||
if tf == 1:
|
||||
self._maybe_rollup_3m_from_1m(code)
|
||||
else:
|
||||
logger.warning("⚠️ [갭보정] %s %dM → REST 빈 응답 (재시도 대상)", code, tf)
|
||||
|
||||
prev_tf = tf
|
||||
self._gap_tf_sleep()
|
||||
|
||||
# only_tfs={1} 만 요청해도 롤업으로 3 준비됐을 수 있음
|
||||
if only_tfs is not None and 1 in only_tfs:
|
||||
self._maybe_rollup_3m_from_1m(code)
|
||||
|
||||
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)
|
||||
all(
|
||||
tf in self._gap_tf_ok.get(code, set())
|
||||
or (tf == 3 and self._gap_tf_already_ok(code, 3))
|
||||
for tf in only_tfs
|
||||
)
|
||||
)
|
||||
|
||||
def get_share_denom(self, code: str) -> float:
|
||||
|
||||
Reference in New Issue
Block a user