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:
2026-07-06 01:27:00 +09:00
parent d8ba01afa4
commit 61c72a8a4c
171 changed files with 176914 additions and 7329 deletions

View File

@@ -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 로 확장 가능."""