feat(tests): 신규 키움 웹소켓 조건검색 및 실시간 조건검색 테스트 추가
변경 사항 ---- - _test_kiwoom_condition_list.py: 키움 웹소켓 조건검색 '목록조회' 기능을 단독으로 테스트하는 스크립트 추가 - _test_kiwoom_condition_realtime.py: 'momentum' 조건식을 실시간으로 등록하고 초기 매칭 종목 리스트 및 실시간 편입/이탈을 수신하는 테스트 스크립트 추가 - _verify_columnar_bitid.py, _verify_shared_e2e_breakout.py, _verify_shared_e2e.py: 공유 메모리 및 dict 간의 데이터 일관성을 검증하는 테스트 추가 영향 ---- - 신규 테스트 스크립트 추가로 키움 웹소켓 API의 기능 검증 및 안정성을 높임 - 기존 기능에 대한 영향 없음 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -4,14 +4,16 @@ kis_trader/network/ws_manager.py — 단일 WebSocket 허브 (Event Bus)
|
||||
설계 목적:
|
||||
* 두 전략(스캘핑/꼬리잡기)이 각자 WS 연결을 띄우면 같은 종목 2번 구독 → 토큰/approval_key
|
||||
경합 + 계정 차단 위험 → 프로세스 전체에서 **단 하나의 WS 세션**만 띄운다.
|
||||
* 기존 ``kis_ws.KISWebSocketPriceCache`` + ``CandleAggregator`` 를 그대로 재사용.
|
||||
* ``kis_trader.ws.kis_ws`` — ``KISWebSocketPriceCache`` + ``CandleAggregator`` 재사용.
|
||||
* 전략별 "구독 관심 종목" 을 **레퍼런스 카운팅**으로 관리. 한 전략이 구독 해제해도
|
||||
다른 전략이 구독 중이면 WS 에서 해제되지 않는다.
|
||||
|
||||
공용 API:
|
||||
- start() / stop()
|
||||
- subscribe(code, owner) / unsubscribe(code, owner)
|
||||
- sync_targets(owner, codes) : 한 전략의 관심 종목을 한 번에 동기화
|
||||
- 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 연결 직후 자동 + 수동 호출)
|
||||
"""
|
||||
@@ -22,9 +24,9 @@ import random
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from typing import Dict, Iterable, List, Optional, Set
|
||||
from typing import Any, Dict, Iterable, List, Optional, Set
|
||||
|
||||
from ..utils.env import get_env_bool, get_env_from_db, get_env_int
|
||||
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")
|
||||
@@ -37,21 +39,33 @@ logger = get_logger("kis_trader.ws")
|
||||
# - 과거 봉 웜업·갭보정 : 키움 ka10080 (1/3/15/60분 native 지원)
|
||||
# → 키움 키 없으면 KIS 1분봉 fallback
|
||||
try:
|
||||
from kis_ws import (
|
||||
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 kis_ws import CandleAggregator, KISWebSocketPriceCache # type: ignore
|
||||
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,
|
||||
@@ -77,14 +91,25 @@ class WSManager:
|
||||
|
||||
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._lock = threading.Lock()
|
||||
# 갭보정 WS 재접속 시: split 모드면 KIS∪키움 관심 종목 전체
|
||||
self._gap_refill_codes: Set[str] = set()
|
||||
|
||||
# ── 갭보정 비동기 파이프라인 ─────────────────────────────
|
||||
# (전략 쓰레드에서 subscribe() 시 동기 REST 호출하면 매수 체크가
|
||||
@@ -96,6 +121,12 @@ class WSManager:
|
||||
self._gap_worker_thread: Optional[threading.Thread] = None
|
||||
# 전체 재갭보정(재접속 시) 중복 트리거 방지
|
||||
self._bulk_refill_running = False
|
||||
# 키움 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
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 시작/종료
|
||||
@@ -126,14 +157,43 @@ class WSManager:
|
||||
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 논블로킹 보장)
|
||||
# 갭보정 · 유통주식수 백그라운드 워커 (subscribe 논블로킹)
|
||||
self._load_share_cache_from_db()
|
||||
self._start_share_meta_worker()
|
||||
self._start_gap_worker()
|
||||
|
||||
# 영구 구독 (KOSPI/KOSDAQ ETF 등)
|
||||
@@ -142,6 +202,8 @@ class WSManager:
|
||||
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)
|
||||
@@ -168,6 +230,140 @@ class WSManager:
|
||||
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
|
||||
|
||||
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],
|
||||
) -> None:
|
||||
"""전략별 후보/보유를 분리 반영. ``WS_SUBSCRIBE_KIS_MINIMAL`` 아니면 레거시와 동일."""
|
||||
cand = {str(c).strip() for c in candidates if c}
|
||||
hold = {str(h).strip() for h in holdings if h}
|
||||
if not self._split_feed_active:
|
||||
self.sync_targets(owner, cand | hold)
|
||||
return
|
||||
if not self._kiwoom_ws:
|
||||
logger.warning(
|
||||
"⚠️ WS 분리 시세 요청이나 키움 WS 없음 → KIS 전체 구독(레거시)으로 폴백",
|
||||
)
|
||||
self.sync_targets(owner, cand | hold)
|
||||
return
|
||||
with self._lock:
|
||||
self._owner_candidates[owner] = cand
|
||||
self._owner_holdings[owner] = hold
|
||||
self._reconcile_split_subscriptions()
|
||||
|
||||
def _reconcile_split_subscriptions(self) -> None:
|
||||
"""KIS/키움 구독 집합을 후보·보유·영구 기준으로 재동기화."""
|
||||
if not (self._split_feed_active and self.ws_cache and self._kiwoom_ws):
|
||||
return
|
||||
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)
|
||||
kis_want = perm | hold_u
|
||||
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)
|
||||
|
||||
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:
|
||||
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)
|
||||
for code in added_kw:
|
||||
self._enqueue_gap_fill(code)
|
||||
|
||||
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):
|
||||
self.ws_cache.unsubscribe(code)
|
||||
if code not in kw_want and self.candle_agg:
|
||||
self.candle_agg.remove_code(code)
|
||||
|
||||
with self._kiwoom_ws._sub_lock:
|
||||
kw_now2 = set(self._kiwoom_ws._subscribed)
|
||||
for code in sorted(kw_now2 - kw_want):
|
||||
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:
|
||||
self.tick_recorder.remove_code(code)
|
||||
if self.trigger_snapshot_recorder and code not in kw_want:
|
||||
self.trigger_snapshot_recorder.remove_code(code)
|
||||
|
||||
self._sync_tick_record_codes()
|
||||
|
||||
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
|
||||
if scope in ("subscribed", "all", "full"):
|
||||
want = subscribed
|
||||
else:
|
||||
# 'candidates' 스코프라도 보유(hold_u)는 항상 포함한다.
|
||||
# 매수 후 종목이 후보 유니버스에서 이탈하면 보유 구간 틱이 끊겨
|
||||
# 백테 '틱청산' 재현이 불가(진입틱만 있고 청산틱 없음)해진다.
|
||||
# 실 체결(손절/익절) 정합을 위해 보유분 틱은 반드시 수집한다.
|
||||
want = cand_u | perm | 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)
|
||||
# ------------------------------------------------------------------
|
||||
@@ -177,6 +373,8 @@ class WSManager:
|
||||
※ 갭보정은 **백그라운드 워커 큐**로 위임하여 전략 쓰레드를 블록하지 않음.
|
||||
(예전: 여기서 REST 4개 TF 동기 호출 → 매수 체크 2~3분 지연)
|
||||
"""
|
||||
if self._split_feed_active:
|
||||
return
|
||||
if not code or not owner:
|
||||
return
|
||||
with self._lock:
|
||||
@@ -188,9 +386,12 @@ class WSManager:
|
||||
self.ws_cache.subscribe(code)
|
||||
# 신규 구독 → 워커에게 갭보정 위임 (논블로킹)
|
||||
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:
|
||||
@@ -204,6 +405,11 @@ class WSManager:
|
||||
self.ws_cache.unsubscribe(code)
|
||||
if self.candle_agg:
|
||||
self.candle_agg.remove_code(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:
|
||||
"""
|
||||
@@ -211,6 +417,8 @@ class WSManager:
|
||||
- 기존 관심 종목 중 없어진 것은 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()))
|
||||
@@ -219,17 +427,76 @@ class WSManager:
|
||||
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 []
|
||||
|
||||
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
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 조회 헬퍼 (전략이 쓰는 API)
|
||||
# ------------------------------------------------------------------
|
||||
def get_price(self, code: str, max_age_sec: float = 5.0) -> Optional[dict]:
|
||||
if not self.ws_cache:
|
||||
return None
|
||||
try:
|
||||
return self.ws_cache.get_price(code, max_age_sec=max_age_sec)
|
||||
except Exception:
|
||||
return None
|
||||
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 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:
|
||||
if self.candle_agg:
|
||||
@@ -287,6 +554,106 @@ class WSManager:
|
||||
with self._gap_lock:
|
||||
self._gap_inflight.discard(code)
|
||||
logger.warning("⚠️ 갭보정 큐 full → %s 스킵 (WS 실시간 누적으로 대체)", code)
|
||||
self._enqueue_share_meta(code)
|
||||
|
||||
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 재접속 시 현재 구독된 전 종목의 갭보정 완료 마커를 리셋하고 재큐잉."""
|
||||
@@ -298,8 +665,11 @@ class WSManager:
|
||||
|
||||
def _bulk():
|
||||
try:
|
||||
with self.ws_cache._sub_lock:
|
||||
codes = sorted(self.ws_cache._subscribed)
|
||||
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()
|
||||
@@ -503,12 +873,51 @@ class WSManager:
|
||||
# 같은 종목 내 타임프레임 전환 사이 짧은 sleep (차트 API 레이트리밋)
|
||||
time.sleep(random.uniform(0.15, 0.3))
|
||||
|
||||
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:
|
||||
raw = get_env_from_db("PERMANENT_WS_CODES", "069500,229200")
|
||||
self._permanent_codes = {c.strip() for c in str(raw).split(",") if c.strip()}
|
||||
"""
|
||||
국내 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 로 확장 가능."""
|
||||
|
||||
Reference in New Issue
Block a user