""" kis_trader/network/ws_manager.py — 단일 WebSocket 허브 (Event Bus) ==================================================================== 설계 목적: * 두 전략(스캘핑/꼬리잡기)이 각자 WS 연결을 띄우면 같은 종목 2번 구독 → 토큰/approval_key 경합 + 계정 차단 위험 → 프로세스 전체에서 **단 하나의 WS 세션**만 띄운다. * ``kis_trader.ws.kis_ws`` — ``KISWebSocketPriceCache`` + ``CandleAggregator`` 재사용. * 전략별 "구독 관심 종목" 을 **레퍼런스 카운팅**으로 관리. 한 전략이 구독 해제해도 다른 전략이 구독 중이면 WS 에서 해제되지 않는다. 공용 API: - start() / stop() - subscribe(code, owner) / unsubscribe(code, owner) (레거시 ref-count; ``WS_SUBSCRIBE_KIS_MINIMAL`` 시 비활성) - sync_targets(owner, codes) : 레거시 한 덩어리 동기화 (분리 모드에선 미사용) - sync_targets_split(owner, candidates, holdings) : KIS 최소 구독 모드용 후보/보유 분리 - set_kiwoom_ws / activate_split_feed : Orchestrator 가 키움 인스턴스 주입 후 분리 구독 ON - get_price(code) / get_candles(code, tf, n) - fill_gap(codes=None) : 갭 보정 (WS 연결 직후 자동 + 수동 호출) """ from __future__ import annotations import queue import random import threading import time from collections import defaultdict from typing import Any, Dict, Iterable, List, Optional, Set from ..utils.env import get_env_bool, get_env_from_db, get_env_int, get_env_float from ..utils.logger import get_logger logger = get_logger("kis_trader.ws") # 기존 kis_ws 모듈 재사용 (검증된 로직 보존 원칙) # 역할 분리 정책 (kis_scalping_ver2 / kis_short_ver3 와 동일): # - 실시간 시세(WS) : KIS 실전키 (is_mock=False 고정) # - 매수/매도 주문·계좌 : KIS (mock 여부는 KIS_MOCK) # - 유니버스(거래량 순위) : KIS REST volume-rank # - 과거 봉 웜업·갭보정 : 키움 ka10080 (1/3/15/60분 native 지원) # → 키움 키 없으면 KIS 1분봉 fallback try: from ..ws.kis_ws import ( CandleAggregator, KISWebSocketPriceCache, _get_kiwoom_creds, get_kiwoom_candles_df, fetch_kiwoom_stock_meta, fetch_kiwoom_stock_meta_detail, ) from ..ws.tick_recorder import TickRecorder from ..ws.trigger_snapshot_recorder import TriggerSnapshotRecorder from ..ws.trigger_eval_recorder import TriggerEvalRecorder _KIS_WS_AVAILABLE = True except ImportError as _e: _KIS_WS_AVAILABLE = False TriggerSnapshotRecorder = None # type: ignore[misc, assignment] TriggerEvalRecorder = None # type: ignore[misc, assignment] # 서브심볼 import 실패 대응 (패키지만 있고 키움 함수 없는 구버전) try: from ..ws.kis_ws import CandleAggregator, KISWebSocketPriceCache # type: ignore from ..ws.tick_recorder import TickRecorder # type: ignore from ..ws.trigger_snapshot_recorder import TriggerSnapshotRecorder # type: ignore from ..ws.trigger_eval_recorder import TriggerEvalRecorder # type: ignore _KIS_WS_AVAILABLE = True _get_kiwoom_creds = None # type: ignore[assignment] get_kiwoom_candles_df = None # type: ignore[assignment] fetch_kiwoom_stock_meta = None # type: ignore[assignment] fetch_kiwoom_stock_meta_detail = None # type: ignore[assignment] logger.warning( "kis_ws 에 키움 함수 없음 → 갭보정 키움 fallback 비활성 " "(KIS 1분봉 전용): %s", _e, ) except ImportError as _e2: logger.warning("kis_ws 모듈 import 실패 → WS 기능 비활성: %s", _e2) class WSManager: """ 단일 WS 허브. 전략은 이 매니저를 공유하고, subscribe/unsubscribe 시 owner 를 전달한다. reference counting 예시: subscribe("005930", owner="SCALP") # refs[005930]={SCALP} → WS subscribe subscribe("005930", owner="SHORT") # refs[005930]={SCALP,SHORT} → (already subscribed) unsubscribe("005930", owner="SCALP") # refs[005930]={SHORT} → keep unsubscribe("005930", owner="SHORT") # refs[005930]=set() → WS unsubscribe """ def __init__(self, *, db, kis_client): self.db = db self.kis_client = kis_client self.ws_cache: Optional["KISWebSocketPriceCache"] = None self.candle_agg: Optional["CandleAggregator"] = None self.tick_recorder: Optional["TickRecorder"] = None self.trigger_snapshot_recorder: Optional["TriggerSnapshotRecorder"] = None self.trigger_eval_recorder: Optional["TriggerEvalRecorder"] = None # KIS 최소 구독 모드: 후보는 키움 WS, KIS 는 영구+보유만 (세션 41 한도 완화) self._kiwoom_ws: Any = None self._split_feed_active: bool = False # owner(전략ID) → 관심 코드 집합 self._owner_codes: Dict[str, Set[str]] = defaultdict(set) # code → 보유 중인 owner 집합 (ref counting) self._code_refs: Dict[str, Set[str]] = defaultdict(set) # 분리 모드: 후보 vs 보유 (전략별) self._owner_candidates: Dict[str, Set[str]] = defaultdict(set) 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() # BaseStrategy 틱매도 등 — 시세 캐시 갱신 리스너 self._price_listeners: list = [] # ── 갭보정 비동기 파이프라인 ───────────────────────────── # (전략 쓰레드에서 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" | "3m" | "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 (재시도 시 스킵) # 최대 재시도 초과 후 force 재큐 차단 (전략 check_buy 매초 fill_gap(force) 폭주 방지) self._gap_give_up_until: Dict[str, float] = {} self._gap_empty_log_ts: Dict[str, float] = {} # 빈응답 로그 스로틀 self._gap_empty_hit_ts: List[float] = [] # 전역 빈응답 회로차단용 self._gap_empty_circuit_until: float = 0.0 self._gap_lock = threading.Lock() 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 # 평일 장시작 1회 — 장외 거짓완료 마커 클리어 + bulk refill (YYYYMMDD) self._gap_session_day: Optional[str] = None # 키움 ka10001 유통/상장주식수 — 전략 공통 (stock_share_meta DB 동기) self._share_cache: Dict[str, Dict[str, int]] = {} self._share_q: "queue.Queue[str]" = queue.Queue(maxsize=1024) self._share_inflight: Set[str] = set() self._share_lock = threading.Lock() self._share_worker_thread: Optional[threading.Thread] = None # ── ls_condition 전략 시세 라우팅 (키움/KIS 와 분리) ───────────── # owner → 해당 전략의 LS 피드 코드 (후보∪보유) self._ls_feed_owners: Dict[str, Set[str]] = defaultdict(set) self._ls_gap_q: "queue.Queue[str]" = queue.Queue(maxsize=512) self._ls_gap_filled: Set[str] = set() self._ls_gap_inflight: Set[str] = set() self._ls_gap_fail: Dict[str, int] = {} self._ls_gap_lock = threading.Lock() self._ls_gap_worker_threads: List[threading.Thread] = [] self._ls_ws_missing_warned: bool = False # ------------------------------------------------------------------ # 시작/종료 # ------------------------------------------------------------------ def start(self) -> bool: """WS 세션 시작. 실패 시 False (봇은 REST 폴백으로 동작).""" if not _KIS_WS_AVAILABLE: logger.warning("kis_ws 미설치 → WS 허브 비활성 (REST 폴백만 동작)") return False # ── [중요] WS 는 데이터 수신용이므로 무조건 실전 서버로 접속 ── # kis_scalping_ver2 와 동일 정책 (모의 계좌라도 시세는 실전 필요) ws_app_key = get_env_from_db("KIS_APP_KEY_REAL", "") or self.kis_client.app_key ws_app_secret = get_env_from_db("KIS_APP_SECRET_REAL", "") or self.kis_client.app_secret if not ws_app_key or not ws_app_secret: logger.warning("KIS 실전 키 없음 → WS 허브 비활성") return False try: self.ws_cache = KISWebSocketPriceCache( app_key=ws_app_key, app_secret=ws_app_secret, is_mock=False, # 시세는 실전 서버 고정 ) # 봉 타임프레임: 스캘핑(1분) + 꼬리잡기(3분) + 추세 필터(15/60분) # SCALP/SHORT 양쪽 전략이 쓰는 모든 TF 포함 tfs = self._resolve_timeframes() self.candle_agg = CandleAggregator(db=self.db, timeframes=tfs) self.ws_cache.attach_candle_aggregator(self.candle_agg) if get_env_bool("WS_TICK_SAVE_ENABLED", True): try: self.tick_recorder = TickRecorder(db=self.db) self.ws_cache.attach_tick_recorder(self.tick_recorder) except Exception as tr_ex: logger.warning("TickRecorder 기동 실패 (봉 집계만 동작): %s", tr_ex) self.tick_recorder = None if get_env_bool("WS_TRIGGER_EVAL_SAVE_ENABLED", True): try: self.trigger_eval_recorder = TriggerEvalRecorder(db=self.db) except Exception as te_ex: logger.warning("TriggerEvalRecorder 기동 실패: %s", te_ex) self.trigger_eval_recorder = None # 레거시: WS 3초 주기 덤프 (기본 OFF — filter_eval 판정 스냅 사용) if get_env_bool("WS_ORDERBOOK_SAVE_ENABLED", False) or get_env_bool( "WS_PROGRAM_SAVE_ENABLED", False, ): try: if TriggerSnapshotRecorder is not None: self.trigger_snapshot_recorder = TriggerSnapshotRecorder(db=self.db) except Exception as ts_ex: logger.warning("TriggerSnapshotRecorder 기동 실패: %s", ts_ex) self.trigger_snapshot_recorder = None ok = self.ws_cache.start() if not ok: logger.warning("WS start() 실패 → 비활성") self.ws_cache = None self.candle_agg = None self.tick_recorder = None return False # 갭보정 · 유통주식수 백그라운드 워커 (subscribe 논블로킹) self._load_share_cache_from_db() self._start_share_meta_worker() self._start_gap_worker() self._start_ls_gap_worker() # 영구 구독 (KOSPI/KOSDAQ ETF 등) self._load_permanent_codes() for code in sorted(self._permanent_codes): self.ws_cache.subscribe(code) self._enqueue_gap_fill(code) logger.info("📡 [영구구독] %s", code) with self._lock: self._gap_refill_codes = set(self._permanent_codes) # 연결 성공 후 자동 갭 보정 등록 (WS 재접속 시 전체 재갭보정) self.ws_cache.set_on_connected_callback(self._trigger_bulk_refill_async) self._reattach_all_price_listeners() 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)), ) return True except Exception as e: logger.error("WS 초기화 예외: %s", e) self.ws_cache = None self.candle_agg = None return False def stop(self) -> None: try: if self.ws_cache: self.ws_cache.stop(clear_subscriptions=True) except Exception as e: logger.debug("WS stop 실패: %s", e) @property def is_active(self) -> bool: return bool(self.ws_cache and self.ws_cache.is_active) # ------------------------------------------------------------------ # 키움 분리 시세 (KIS=영구+보유, 후보=키움) — Orchestrator 가 주입 # ------------------------------------------------------------------ def set_kiwoom_ws(self, kiwoom_ws: Any) -> None: """키움 WS 인스턴스 (기동 후 주입). ``activate_split_feed`` 전에 설정.""" self._kiwoom_ws = kiwoom_ws self._reattach_all_price_listeners() def activate_split_feed(self, active: bool) -> None: """``WS_SUBSCRIBE_KIS_MINIMAL`` + 키움 준비 완료 후 True → 후보 구독을 키움으로.""" self._split_feed_active = bool(active and self._kiwoom_ws and self.ws_cache) if self._split_feed_active: logger.info("✅ WS 분리 시세 활성: KIS=PERMANENT∪보유, 후보·검증=키움") def sync_targets_split( self, owner: str, candidates: Iterable[str], holdings: Iterable[str], *, ls_feed: bool = False, ) -> None: """전략별 후보/보유를 분리 반영. ``WS_SUBSCRIBE_KIS_MINIMAL`` 아니면 레거시와 동일. ls_feed=True (``UNIVERSE_SOURCE=ls_condition``): - LS US3 구독 → 틱·현재가 (ls_ws_ticks 와 동일 파이프) - 키움 구독·갭보정·분봉은 **그대로** (pure_ls 제외 안 함) """ cand = {str(c).strip() for c in candidates if c} hold = {str(h).strip() for h in holdings if h} with self._lock: if ls_feed: self._ls_feed_owners[owner] = set(cand | hold) else: self._ls_feed_owners.pop(owner, None) if not self._split_feed_active: if ls_feed: self._reconcile_ls_feed_subscriptions() self.sync_targets(owner, cand | hold) return if not self._kiwoom_ws: logger.warning( "⚠️ WS 분리 시세 요청이나 키움 WS 없음 → KIS 전체 구독(레거시)으로 폴백", ) if ls_feed: self._reconcile_ls_feed_subscriptions() self.sync_targets(owner, cand | hold) return with self._lock: self._owner_candidates[owner] = cand self._owner_holdings[owner] = hold self._reconcile_split_subscriptions() self._reconcile_ls_feed_subscriptions() def _ls_feed_codes_locked(self) -> Set[str]: """호출자 _lock 보유 가정.""" out: Set[str] = set() for s in self._ls_feed_owners.values(): out |= s return out def _non_ls_codes_locked(self) -> Set[str]: """ls_feed 가 아닌 전략의 후보∪보유. 호출자 _lock 보유.""" ls_owners = set(self._ls_feed_owners.keys()) out: Set[str] = set() for own, s in self._owner_candidates.items(): if own not in ls_owners: out |= s for own, s in self._owner_holdings.items(): if own not in ls_owners: out |= s # 레거시 sync_targets 경로 for own, s in self._owner_codes.items(): if own not in ls_owners: out |= s return out def _pure_ls_codes_locked(self) -> Set[str]: """예전: LS 전용 코드를 키움/갭에서 빼던 집합. 지금은 비움 — ls_condition 도 키움 갭·분봉을 쓰고, LS 는 틱만. """ return set() def is_ls_feed_code(self, code: str) -> bool: """LS 틱·현재가 라우팅 대상 (갭/분봉은 키움).""" with self._lock: return code in self._ls_feed_codes_locked() def _get_ls_ws(self): try: from ..ws.ls_ws import get_active_ls_ws return get_active_ls_ws() except Exception: return None def _reconcile_ls_feed_subscriptions(self) -> None: """ls_condition 코드를 LS US3 에만 sync — 갭(t8412)은 돌리지 않음.""" with self._lock: owners = { str(o): set(codes) for o, codes in self._ls_feed_owners.items() } if not owners: return ls_ws = self._get_ls_ws() if ls_ws is None: if not self._ls_ws_missing_warned: logger.warning( "⚠️ ls_condition 틱용 LS WS 미기동 — " "AFR/틱 수신 실패 가능 (main LS WS 확인)", ) self._ls_ws_missing_warned = True return self._ls_ws_missing_warned = False for owner, codes in owners.items(): try: ls_ws.sync_owner_codes(owner, codes) except Exception as e: logger.warning("LS sync_owner_codes(%s) 실패: %s", owner, e) def _enqueue_ls_gap_fill(self, code: str, *, force: bool = False, priority: bool = False) -> None: if not code or not get_env_bool("LS_GAP_FILL_ENABLED", True): return fail_max = max(1, get_env_int("LS_GAP_FILL_FAIL_MAX", 5)) with self._ls_gap_lock: if self._ls_gap_fail.get(code, 0) >= fail_max and not force: return if code in self._ls_gap_inflight: return if code in self._ls_gap_filled and not force: return if force: self._ls_gap_filled.discard(code) self._ls_gap_inflight.add(code) try: self._ls_gap_q.put_nowait(code) except queue.Full: with self._ls_gap_lock: self._ls_gap_inflight.discard(code) logger.warning("⚠️ LS 갭보정 큐 full → %s 스킵", code) def _start_ls_gap_worker(self) -> None: if self._ls_gap_worker_threads: alive = [t for t in self._ls_gap_worker_threads if t.is_alive()] if alive: return n = max(1, min(get_env_int("LS_GAP_FILL_WORKERS", 1), 2)) threads = [] for i in range(n): t = threading.Thread( target=self._ls_gap_worker_loop, name=f"WS-LSGapWorker-{i}", daemon=True, ) t.start() threads.append(t) self._ls_gap_worker_threads = threads logger.info("✅ LS 갭보정 워커 시작 (t8412, workers=%d)", n) def _ls_gap_worker_loop(self) -> None: while True: try: try: code = self._ls_gap_q.get(timeout=1.0) except queue.Empty: continue if not code: continue ok = False try: ls_ws = self._get_ls_ws() if ls_ws is None: logger.debug("LS 갭 워커: LS WS 없음 code=%s", code) else: n = int(ls_ws.fill_gap_from_rest(code) or 0) ok = n > 0 if not ok: logger.debug("LS 갭 빈응답 code=%s", code) except Exception as e: logger.warning("LS 갭 실패 %s: %s", code, e) with self._ls_gap_lock: self._ls_gap_inflight.discard(code) if ok: self._ls_gap_filled.add(code) self._ls_gap_fail.pop(code, None) else: self._ls_gap_fail[code] = int(self._ls_gap_fail.get(code, 0)) + 1 except Exception as e: logger.debug("LS gap worker: %s", e) time.sleep(0.5) def _reconcile_split_subscriptions(self) -> None: """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(): cand_u |= s hold_u: Set[str] = set() for s in self._owner_holdings.values(): hold_u |= s perm = set(self._permanent_codes) pure_ls = self._pure_ls_codes_locked() - perm # ls_condition 전용 종목은 키움/KIS 후보·갭에서 제외 (교차 폭주 방지) cand_u_kw = cand_u - pure_ls hold_u_kw = hold_u - pure_ls kis_want = perm | hold_u_kw kw_want = cand_u_kw | hold_u_kw | perm tick_to_agg = set(cand_u_kw - hold_u_kw) 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) except Exception: pass try: if self.candle_agg and hasattr(self.candle_agg, "set_incremental_volume_codes"): self.candle_agg.set_incremental_volume_codes(set(tick_to_agg)) except Exception: pass with self._kiwoom_ws._sub_lock: kw_now = set(self._kiwoom_ws._subscribed) with self.ws_cache._sub_lock: kis_now = set(self.ws_cache._subscribed) 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: added_kw = [] 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() } pure_ls_now = self._pure_ls_codes_locked() for code in added_kw: if code in pure_ls_now: continue # 방어: LS 전용은 키움 갭 enqueue 금지 if code in self._permanent_codes: self._enqueue_gap_fill(code) else: # 전 후보 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: self._remove_candle_ram(code) 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: self._remove_candle_ram(code) 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 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: return scope = (get_env_from_db("WS_TICK_RECORD_SCOPE", "candidates") or "candidates").strip().lower() with self._lock: perm = set(self._permanent_codes) if self._split_feed_active: cand_u: Set[str] = set() for s in self._owner_candidates.values(): cand_u |= s hold_u: Set[str] = set() 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: # 'candidates' 스코프라도 보유(hold_u)는 항상 포함한다. # 매수 후 종목이 후보 유니버스에서 이탈하면 보유 구간 틱이 끊겨 # 백테 '틱청산' 재현이 불가(진입틱만 있고 청산틱 없음)해진다. # 실 체결(손절/익절) 정합을 위해 보유분 틱은 반드시 수집한다. want = cand_u | perm | hold_u | (subscribed - cand_u - hold_u) else: subscribed = set(perm) for refs in self._code_refs.values(): subscribed |= set(refs) want = subscribed if scope in ("subscribed", "all", "full") else subscribed self.tick_recorder.set_record_codes(want) if self.trigger_snapshot_recorder: self.trigger_snapshot_recorder.set_record_codes(want) # ------------------------------------------------------------------ # 구독 관리 (Reference Counting) # ------------------------------------------------------------------ def subscribe(self, code: str, owner: str) -> None: """ 한 전략(owner)이 해당 종목에 관심 등록. ※ 갭보정은 **백그라운드 워커 큐**로 위임하여 전략 쓰레드를 블록하지 않음. (예전: 여기서 REST 4개 TF 동기 호출 → 매수 체크 2~3분 지연) """ if self._split_feed_active: return if not code or not owner: return with self._lock: self._owner_codes[owner].add(code) first_ref = not self._code_refs[code] self._code_refs[code].add(owner) if first_ref and self.ws_cache: self.ws_cache.subscribe(code) # 신규 구독 → 워커에게 갭보정 위임 (논블로킹) — 키움/KIS 경로 self._enqueue_gap_fill(code) self._sync_tick_record_codes() def unsubscribe(self, code: str, owner: str) -> None: """한 전략(owner)이 관심 해제. 다른 전략이 아직 들고 있으면 WS 는 유지.""" if self._split_feed_active: return if not code or not owner: return with self._lock: self._owner_codes[owner].discard(code) if owner in self._code_refs.get(code, set()): self._code_refs[code].discard(owner) still_refs = bool(self._code_refs.get(code)) is_permanent = code in self._permanent_codes if not still_refs and not is_permanent and self.ws_cache: self.ws_cache.unsubscribe(code) self._remove_candle_ram(code) if self.tick_recorder: self.tick_recorder.remove_code(code) if self.trigger_snapshot_recorder: self.trigger_snapshot_recorder.remove_code(code) self._sync_tick_record_codes() def sync_targets(self, owner: str, codes: Iterable[str]) -> None: """ 한 전략의 관심 종목 목록을 통째로 동기화. - 기존 관심 종목 중 없어진 것은 unsubscribe - 새로 추가된 것은 subscribe """ if self._split_feed_active: return new_set = {c for c in codes if c} with self._lock: cur = set(self._owner_codes.get(owner, set())) for code in sorted(cur - new_set): self.unsubscribe(code, owner) for code in sorted(new_set - cur): self.subscribe(code, owner) self._sync_tick_record_codes() def get_recent_ticks(self, code: str, limit: int = 100) -> list: """RAM 링버퍼 최근 체결 틱 (B안 봉 내 돌파 등).""" if self.tick_recorder: try: return self.tick_recorder.get_recent_ticks(code, limit=limit) except Exception: return [] return [] # ------------------------------------------------------------------ # 조회 헬퍼 (전략이 쓰는 API) # ------------------------------------------------------------------ def get_price(self, code: str, max_age_sec: float = 5.0) -> Optional[dict]: # ls_condition 전략 코드 → LS WS 우선 if self.is_ls_feed_code(code): ls_ws = self._get_ls_ws() if ls_ws is not None: try: p = ls_ws.get_price(code, max_age_sec=max_age_sec) if p: return p except Exception: pass else: logger.debug("LS 피드 코드이나 LS WS 없음: %s", code) if self.ws_cache: try: p = self.ws_cache.get_price(code, max_age_sec=max_age_sec) if p: return p except Exception: pass if self._split_feed_active and self._kiwoom_ws: try: return self._kiwoom_ws.get_price(code, max_age_sec=max_age_sec) except Exception: return None return None def register_price_listener(self, callback) -> None: """현재가 틱 갱신 콜백. callback(code, price, data_dict). BaseStrategy 틱매도용.""" if callback is None: return if callback not in self._price_listeners: self._price_listeners.append(callback) self._attach_price_listener(callback) def _attach_price_listener(self, callback) -> None: for src in (self.ws_cache, self._kiwoom_ws, self._get_ls_ws()): if src is not None and hasattr(src, "add_price_listener"): try: src.add_price_listener(callback) except Exception: pass def unregister_price_listener(self, callback) -> None: if callback is None: return try: self._price_listeners.remove(callback) except ValueError: pass for src in (self.ws_cache, self._kiwoom_ws, self._get_ls_ws()): if src is not None and hasattr(src, "remove_price_listener"): try: src.remove_price_listener(callback) except Exception: pass def _reattach_all_price_listeners(self) -> None: """키움/KIS WS 기동·교체 후 기존 리스너 재연결.""" for cb in list(self._price_listeners): self._attach_price_listener(cb) def get_orderbook_snapshot(self, code: str, max_age_sec: float = 3.0): """키움 0D 호가 스냅샷 (분리 시세·키움 WS 활성 시).""" if self._kiwoom_ws and hasattr(self._kiwoom_ws, "get_orderbook_snapshot"): try: snap = self._kiwoom_ws.get_orderbook_snapshot(code, max_age_sec=max_age_sec) if snap is not None: return snap except Exception: pass return None def get_orderbook(self, code: str, max_age_sec: float = 3.0) -> Optional[dict]: """호가 dict (KIS REST 호환 bidp*) — 키움 WS 캐시 우선.""" if self._kiwoom_ws and hasattr(self._kiwoom_ws, "get_orderbook"): try: ob = self._kiwoom_ws.get_orderbook(code, max_age_sec=max_age_sec) if ob: return ob except Exception: pass return None def get_program_snapshot(self, code: str, max_age_sec: float = 30.0): """키움 0w 프로그램매매 스냅샷 (분리 시세·키움 WS 활성 시).""" if self._kiwoom_ws and hasattr(self._kiwoom_ws, "get_program_snapshot"): try: snap = self._kiwoom_ws.get_program_snapshot(code, max_age_sec=max_age_sec) if snap is not None: return snap except Exception: pass return None def get_candles(self, code: str, tf: int, n: int = 50) -> list: # ls_condition 도 분봉은 키움 갭·candle_agg (틱만 LS) if self.candle_agg: try: return self.candle_agg.get_candles(code, tf, n) except Exception: return [] # CandleAggregator 없으면 DB 폴백 try: return self.db.get_ws_candles(code, tf, limit=n, confirmed_only=True) except Exception: return [] def get_current_candle(self, code: str, tf: int) -> Optional[dict]: """진행 중 봉 (RAM, is_confirmed=0) — B안 거래량·양봉 판정용.""" if self.candle_agg: try: return self.candle_agg.get_current_candle(code, tf) except Exception: return None return None def fill_gap( self, codes: Optional[Iterable[str]] = None, *, force: bool = False, ) -> None: """갭 보정 — 전부 키움/KIS 기존 경로 (LS t8412 안 씀). force=True: 이미 ``_gap_filled`` 여도 재큐. """ self._maybe_arm_session_gap_refill() if codes is None: self._trigger_bulk_refill_async() return for c in codes: self._enqueue_gap_fill(c, force=bool(force), priority=bool(force)) def _maybe_arm_session_gap_refill(self) -> None: """평일 장시작 세션 1회: 장외 거짓완료 마커 제거 + 구독 종목 bulk refill. 주말/장외에 ``_gap_filled`` 만 찍히면 개장 직후 REST 없이 have=0 레이스가 난다. """ if not self._is_market_hours(): return day = time.strftime("%Y%m%d") if self._gap_session_day == day: return self._gap_session_day = day logger.info( "🔄 [갭보정-장시작] 세션 %s 오픈 → 완료마커 클리어 + bulk refill", day, ) # debounce 우회: 세션 오픈은 강제 (직전 장외 bulk 와 충돌해도 재실행) self._bulk_refill_last_ts = 0.0 self._trigger_bulk_refill_async() def _clear_gap_fill_state(self, code: str) -> None: """RAM 봉 삭제와 짝 — 갭보정 완료 마커 해제. EXIT→remove_code 후 ``_gap_filled`` 가 남으면 재ENTER 시 갭보정이 스킵되고 WS 실시간 몇 봉만으로 '봉부족'이 난다 (전 전략 공통). """ if not code: return with self._gap_lock: self._gap_filled.discard(code) self._gap_tf_ok.pop(code, None) self._gap_retry_count.pop(code, None) self._gap_give_up_until.pop(code, None) self._gap_empty_log_ts.pop(code, None) def _gap_give_up_sec(self) -> float: """최대 재시도 초과 후 force 재큐 차단 시간(초). 계정 REST 폭주 방지.""" return float(get_env_float("WS_GAP_FILL_GIVE_UP_SEC", 300.0)) def _gap_in_give_up(self, code: str, *, now: Optional[float] = None) -> bool: """포기 쿨다운 중이면 True. 만료 시 카운터 리셋 후 False.""" ts = float(now if now is not None else time.time()) with self._gap_lock: until = float(self._gap_give_up_until.get(code, 0.0) or 0.0) if until <= 0: return False if ts < until: return True self._gap_give_up_until.pop(code, None) self._gap_retry_count.pop(code, None) return False def _mark_gap_give_up(self, code: str) -> None: """REST 실패 상한 도달 — 전략 force 재큐를 일정 시간 무시.""" sec = self._gap_give_up_sec() if sec <= 0 or not code: return until = time.time() + sec with self._gap_lock: self._gap_give_up_until[code] = until logger.warning( "🛑 [갭보정] %s 포기 쿨다운 %.0fs — force 재큐 차단 (REST 폭주 방지)", code, sec, ) def _gap_empty_circuit_open(self) -> bool: """짧은 구간에 빈응답이 몰리면 전역 갭보정 REST 일시 정지.""" now = time.time() with self._gap_lock: if now < float(self._gap_empty_circuit_until or 0.0): return True return False def _note_gap_empty_response(self, code: str, tf: int) -> None: """빈응답 기록 + 로그 스로틀 + 전역 회로차단 갱신.""" now = time.time() win = float(get_env_float("WS_GAP_FILL_EMPTY_CIRCUIT_WINDOW_SEC", 10.0)) max_hits = get_env_int("WS_GAP_FILL_EMPTY_CIRCUIT_MAX", 40) pause = float(get_env_float("WS_GAP_FILL_EMPTY_CIRCUIT_PAUSE_SEC", 60.0)) log_every = float(get_env_float("WS_GAP_FILL_EMPTY_LOG_SEC", 30.0)) opened = False with self._gap_lock: last_log = float(self._gap_empty_log_ts.get(code, 0.0) or 0.0) do_log = (now - last_log) >= log_every if do_log: self._gap_empty_log_ts[code] = now if win > 0 and max_hits > 0: self._gap_empty_hit_ts.append(now) cut = now - win self._gap_empty_hit_ts = [t for t in self._gap_empty_hit_ts if t >= cut] if len(self._gap_empty_hit_ts) >= max_hits and pause > 0: if now >= float(self._gap_empty_circuit_until or 0.0): self._gap_empty_circuit_until = now + pause self._gap_empty_hit_ts.clear() opened = True if do_log: logger.warning( "⚠️ [갭보정] %s %dM → REST 빈 응답 (재시도 대상)", code, tf, ) if opened: logger.error( "🚨 [갭보정] 빈응답 폭주 → 전역 REST %.0fs 정지 " "(window=%.0fs max=%d) — 계정 한도 보호", pause, win, max_hits, ) def _remove_candle_ram(self, code: str) -> None: """구독 해제 시 RAM 봉 정리 + 갭보정 재실행 가능하도록 상태 리셋.""" if not code: return if self.candle_agg: try: self.candle_agg.remove_code(code) except Exception: pass self._clear_gap_fill_state(code) # ------------------------------------------------------------------ # 내부: 갭 보정 — 백그라운드 워커 파이프라인 # ------------------------------------------------------------------ def _start_gap_worker(self) -> None: """갭보정 백그라운드 워커 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 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, ) @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, *, force: bool = False, priority: bool = False, mode: str = "full", ) -> None: """구독 직후 호출 — 갭보정 큐에 논블로킹 등록. 중복 방지: - 이미 완료(`_gap_filled`) → 스킵 (force=True 시 재시도) - 이미 큐/처리 중(`_gap_inflight`) → 스킵 Args: priority: True 이면 우선 큐(후보 종목 1M/3M 웜업 등) mode: ``"1m"`` / ``"3m"`` = 해당 TF만 먼저, ``"full"`` = 설정된 전 TF """ if not code: return mode_key = str(mode).strip().lower() fill_mode = mode_key if mode_key in ("1m", "3m") else "full" # 빈응답 회로차단 / 종목 포기 쿨다운 — force 여부와 무관하게 REST 재큐 차단 if self._gap_empty_circuit_open(): return if self._gap_in_give_up(code): return with self._gap_lock: 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: 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(): return t = threading.Thread( target=self._share_meta_worker_loop, name="WS-ShareMetaWorker", daemon=True, ) t.start() self._share_worker_thread = t logger.info("✅ 유통주식수 워커 시작 (stock_share_meta / ka10001)") def _load_share_cache_from_db(self) -> None: try: from kis_trader.share.stock_share import ( load_stock_share_meta_map, merge_share_meta_into_cache, ) db_map = load_stock_share_meta_map(self.db) merge_share_meta_into_cache(self._share_cache, db_map) if db_map: logger.info("📊 stock_share_meta DB 캐시: %d종목", len(db_map)) except Exception as e: logger.debug("stock_share_meta DB 로드 실패: %s", e) def _enqueue_share_meta(self, code: str) -> None: """구독 종목 유통주식수 큐 등록 — ``dstr_stk`` 없을 때만 ka10001 호출.""" if not code: return try: from kis_trader.share.stock_share import codes_missing_dstr if not codes_missing_dstr(self._share_cache, [code]): return except Exception: pass with self._share_lock: if code in self._share_inflight: return self._share_inflight.add(code) try: self._share_q.put_nowait(code) except queue.Full: with self._share_lock: self._share_inflight.discard(code) logger.warning("⚠️ 유통주식수 큐 full → %s 스킵", code) def _share_meta_worker_loop(self) -> None: kw_key = kw_secret = None kw_mock = False kw_resolved = False while True: try: code = self._share_q.get(timeout=1.0) except queue.Empty: continue if code is None: return try: if not kw_resolved: kw_key, kw_secret, kw_mock = self._get_kiwoom_credentials() kw_resolved = True if kw_key and kw_secret and fetch_kiwoom_stock_meta_detail is not None: # 실매: 종목당 1회 시도 — 실패 시 다음 구독/매수체크 턴에 재큐 (백필 스크립트만 다회 재시도) res = fetch_kiwoom_stock_meta_detail( code, kw_key, kw_secret, is_mock=kw_mock, max_retries=1, ) meta = res.get("meta") if res.get("ok") else None if meta: from kis_trader.share.stock_share import apply_fetched_meta apply_fetched_meta(self._share_cache, self.db, code, meta) logger.debug( "📊 [ka10001] %s flo=%s dstr=%s", code, meta.get("flo_stk"), meta.get("dstr_stk"), ) elif res.get("reason") == "rate_limit_1700": logger.warning( "📊 [ka10001] %s 레이트리밋 — 다음 턴 재시도 (큐 유지 안 함)", code, ) elif res.get("reason"): logger.debug( "📊 [ka10001] %s 실패 %s %s", code, res.get("reason"), res.get("return_msg"), ) meta_sleep = get_env_float("STOCK_SHARE_META_SLEEP_SEC", 1.0) time.sleep(random.uniform(meta_sleep * 0.9, meta_sleep * 1.1)) except Exception as e: logger.debug("유통주식수 워커 예외 (%s): %s", code, e) finally: with self._share_lock: self._share_inflight.discard(code) self._share_q.task_done() def ensure_share_meta(self, codes: Iterable[str]) -> None: """전략 공통 — 여러 종목 유통주식수 큐 일괄 등록.""" for code in codes: self._enqueue_share_meta(str(code or "").strip()) def _trigger_bulk_refill_async(self) -> None: """WS 재접속 시 현재 구독된 전 종목의 갭보정 완료 마커를 리셋하고 재큐잉.""" if not (self.ws_cache and self.candle_agg): 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(): try: if self._split_feed_active and self._gap_refill_codes: codes = sorted(self._gap_refill_codes) else: with self.ws_cache._sub_lock: codes = sorted(self.ws_cache._subscribed) # 재접속이므로 모든 종목 갭보정 재실행 with self._gap_lock: self._gap_filled.clear() self._gap_tf_ok.clear() logger.info( "🔄 [갭보정-전체] WS 재접속 → %d종목 큐 재등록", len(codes), ) for code in codes: self._enqueue_gap_fill(code) finally: self._bulk_refill_running = False threading.Thread(target=_bulk, name="WS-BulkRefill", daemon=True).start() def _gap_worker_loop(self, worker_id: int = 0) -> None: """워커 루프: 공유 큐에서 코드 꺼내 ka10080 갭보정 (N워커 병렬).""" kw_key = kw_secret = None kw_mock = False while True: code, from_prio = self._dequeue_gap_fill() if not code: continue with self._gap_lock: gap_mode = self._gap_mode.get(code, "full") partial_tf: Optional[int] = ( 1 if gap_mode == "1m" else (3 if gap_mode == "3m" else None) ) # 장시작 세션 암 — 워커가 장중 첫 작업을 잡을 때도 보장 self._maybe_arm_session_gap_refill() # 장중만 REST. 장외는 완료 마커를 찍지 않음(거짓완료 → 개장 have=0 방지). 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) if from_prio: self._gap_prio_q.task_done() else: self._gap_q.task_done() continue # 전역 빈응답 회로차단 중이면 REST 호출 없이 큐만 비움 (재큐는 enqueue가 막음) if self._gap_empty_circuit_open(): with self._gap_lock: self._gap_inflight.discard(code) self._gap_mode.pop(code, None) if from_prio: self._gap_prio_q.task_done() else: self._gap_q.task_done() time.sleep(0.2) continue if kw_key is None: kw_key, kw_secret, kw_mock = self._get_kiwoom_credentials() 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", ) # 키움 ka10080 유량=5 — 워커 수 > 세마포어면 대기만 늘어남 logger.info( "🔧 [갭보정] ka10080 MAX_INFLIGHT=%d (유량=5 보호)", max(1, min(get_env_int("KIWOOM_KA10080_MAX_INFLIGHT", 2), 4)), ) self._gap_worker_boot_logged = True try: 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 finally: _give_up_code: Optional[str] = None with self._gap_lock: self._gap_inflight.discard(code) self._gap_mode.pop(code, None) 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): 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) self._gap_give_up_until.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 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 %dM 실패 → %ds 후 우선 재시도 (%d/%d)", code, partial_tf, int(delay), retries, max_retries, ) threading.Timer( delay, lambda c=code, m=retry_mode: self._enqueue_gap_fill( c, force=True, priority=True, mode=m, ), ).start() else: logger.warning( "⚠️ [갭보정] %s %dM 최대 재시도 초과 — WS 틱 누적으로 대체", code, partial_tf, ) self._gap_filled.add(code) _give_up_code = code elif ok: self._gap_filled.add(code) self._gap_retry_count.pop(code, None) self._gap_give_up_until.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) _give_up_code = code if _give_up_code: self._mark_gap_give_up(_give_up_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: """ 09:00~15:30 KST 평일 여부. KIS 모의투자 서버는 장외시간 inquire-time-itemchartprice 호출에 HTTP 500 을 던지므로, 갭보정 REST 호출은 장중에만 시도한다. (키움 ka10080 은 장외에도 동작하지만, 매매 자체가 장중에만 의미 있으므로 통일) """ now = time.localtime() if now.tm_wday >= 5: # 토/일 return False hhmm = now.tm_hour * 100 + now.tm_min return 900 <= hhmm <= 1530 def _get_kiwoom_credentials(self): """ 키움 분봉 갭보정·유통주식수(ka10001)용 키 조회. 시세 REST 는 **매매 KIS_MOCK 과 분리** — WS·조건검색과 동일하게 실키 우선. 토글 결정 우선순위 ------------------ 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)`` 반환 → 키움 비활성 (``WS_GAP_FILL_KIS_FALLBACK`` ON 일 때만 KIS REST 폴백) 주의 ---- 레거시 폴백은 키-도메인이 어긋나면 키움이 ``8030`` 으로 거부한다. (예: 모의 전용 키를 실전 도메인에 던지면 8030.) 폴백 사용 시 로그 한 줄로 명시한다. Returns: (app_key, app_secret, is_mock) """ if get_kiwoom_candles_df is None: return None, None, False try: # ── 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: 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: kw_key = (get_env_from_db("KIWOOM_APP_KEY_MOCK", "") or "").strip() kw_secret = (get_env_from_db("KIWOOM_APP_SECRET_MOCK", "") or "").strip() else: kw_key = (get_env_from_db("KIWOOM_APP_KEY_REAL", "") or "").strip() kw_secret = (get_env_from_db("KIWOOM_APP_SECRET_REAL", "") or "").strip() # ── 3. 레거시 단일 필드 폴백 (KIWOOM_APP_KEY/_SECRET) ──── if not kw_key or not kw_secret: legacy_key = (get_env_from_db("KIWOOM_APP_KEY", "") or "").strip() legacy_secret = (get_env_from_db("KIWOOM_APP_SECRET", "") or "").strip() if legacy_key and legacy_secret: kw_key = kw_key or legacy_key kw_secret = kw_secret or legacy_secret logger.info( "🔧 [키움] %s 슬롯 비어있어 LEGACY KIWOOM_APP_KEY 폴백 사용 " "(키-도메인 불일치 시 8030 발생 가능)", "MOCK" if is_mock else "REAL", ) if not kw_key or not kw_secret: return None, None, is_mock return kw_key, kw_secret, is_mock except Exception as e: 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은 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) 먼저, 이후 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"), "1", ) 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_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: 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: 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, *, kw_key: Optional[str] = None, kw_secret: Optional[str] = None, kw_mock: bool = False, only_tfs: Optional[Set[int]] = None, ) -> bool: """ 단일 종목 갭 보정 — 워커 스레드 전용 (전략 쓰레드에서 직접 호출 금지). 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 False if not self._is_market_hours() and not get_env_bool("WS_GAP_FILL_OFF_HOURS", False): return False if self._gap_empty_circuit_open(): return False if self._all_gap_tfs_ok(code): return True 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 롤업 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 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) 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=tf_limit, ) except Exception as 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: try: df = self.kis_client.get_minute_chart( code, period=str(tf), limit=tf_limit, ) except Exception as e: logger.debug("KIS 갭보정 실패 (%s %dM): %s", code, tf, e) 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: self._note_gap_empty_response(code, tf) # 한 TF 빈응답이면 같은 패스의 나머지 TF REST도 생략 (1M+3M 이중 폭격 방지) if get_env_bool("WS_GAP_FILL_ABORT_TF_ON_EMPTY", True): break 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()) or (tf == 3 and self._gap_tf_already_ok(code, 3)) for tf in only_tfs ) ) def get_share_denom(self, code: str) -> float: """ 1분 회전율 분모 — 키움 ka10001 + stock_share_meta 캐시 (전략 공통). ``STOCK_SHARE_DENOM`` / ``BREAKOUT_SHARE_DENOM``: ``dstr_stk``(유통, 기본) | ``flo_stk``. """ from kis_trader.share.stock_share import ( codes_missing_dstr, share_denom_from_meta, ) code = str(code or "").strip() if codes_missing_dstr(self._share_cache, [code]): self._enqueue_share_meta(code) return 0.0 return share_denom_from_meta(self._share_cache.get(code, {})) # ------------------------------------------------------------------ # 영구 구독 / 타임프레임 해석 # ------------------------------------------------------------------ def _load_permanent_codes(self) -> None: """ 국내 WS(H0STCNT0) 영구구독 코드 로드. 우선순위: permanent_subscriptions 테이블(KR·enabled) → 없으면 env PERMANENT_WS_CODES. ※ 해외(US) 코드는 국내 WS로 받을 수 없으므로 여기서 제외한다. (해외는 별도 KISOverseasWebSocket(HDFSCNT0)이 담당) """ codes: set = set() # 1) 통합 영구구독 테이블에서 KR(국내) 코드만 로드 try: import permanent_subs as _ps # 통합 영구구독 테이블 헬퍼 for row in _ps.codes_by_market(self.db, "KR", enabled_only=True): c = str(row.get("code", "")).strip() if c: codes.add(c) except Exception as e: logger.debug("permanent_subscriptions 테이블 로드 실패(env 폴백): %s", e) # 2) 테이블이 비어 있으면 env 폴백 (국내 6자리 숫자코드만 채택 → 해외코드 혼입 차단) if not codes: raw = get_env_from_db("PERMANENT_WS_CODES", "069500,229200") for c in str(raw).split(","): c = c.strip() if c.isdigit() and len(c) == 6: codes.add(c) self._permanent_codes = codes def _resolve_timeframes(self) -> List[int]: """SCALP 1분 + SHORT 3분 + 추세 15/60분. env 로 확장 가능.""" tf_raw = get_env_from_db("WS_TIMEFRAMES", "1,3,15,60") try: tfs = [int(x.strip()) for x in str(tf_raw).split(",") if x.strip()] # 최소 1분, 3분은 포함 보장 (두 전략 필수 TF) for must in (1, 3): if must not in tfs: tfs.append(must) return sorted(set(tfs)) except Exception: return [1, 3, 15, 60]