Changes: - Introduced new files for strategy definitions and study names. - Enhanced `backtest_web.py` with functions to handle integer display prices and trade data formatting. - Updated backtesting logic to incorporate end-of-day (EOD) parameters for breakout and momentum strategies. - Added EOD configuration options in the database and parameter search files. Impact: - These changes improve the modularity and usability of the backtesting framework, allowing for better integration of EOD strategies and clearer trade data presentation.
1176 lines
52 KiB
Python
1176 lines
52 KiB
Python
"""
|
||
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._lock = threading.Lock()
|
||
# 갭보정 WS 재접속 시: split 모드면 KIS∪키움 관심 종목 전체
|
||
self._gap_refill_codes: Set[str] = set()
|
||
|
||
# ── 갭보정 비동기 파이프라인 ─────────────────────────────
|
||
# (전략 쓰레드에서 subscribe() 시 동기 REST 호출하면 매수 체크가
|
||
# 수 분간 블로킹됨 → 백그라운드 워커 큐로 이관)
|
||
self._gap_q: "queue.Queue[str]" = queue.Queue(maxsize=1024)
|
||
self._gap_prio_q: "queue.Queue[str]" = queue.Queue(maxsize=512)
|
||
self._gap_mode: Dict[str, str] = {} # code → "1m" | "full"
|
||
self._gap_filled: Set[str] = set() # 이미 갭보정 완료한 코드
|
||
self._gap_inflight: Set[str] = set() # 큐에 등록/처리 중인 코드
|
||
self._gap_retry_count: Dict[str, int] = {} # TF 실패 시 재시도 카운터
|
||
self._gap_tf_ok: Dict[str, Set[int]] = {} # 종목별 성공한 TF (재시도 시 스킵)
|
||
self._gap_lock = threading.Lock()
|
||
self._gap_worker_threads: List[threading.Thread] = []
|
||
self._gap_worker_boot_logged = False
|
||
# 전체 재갭보정(재접속 시) 중복 트리거 방지
|
||
self._bulk_refill_running = False
|
||
self._bulk_refill_last_ts: float = 0.0
|
||
# 키움 ka10001 유통/상장주식수 — 전략 공통 (stock_share_meta DB 동기)
|
||
self._share_cache: Dict[str, Dict[str, int]] = {}
|
||
self._share_q: "queue.Queue[str]" = queue.Queue(maxsize=1024)
|
||
self._share_inflight: Set[str] = set()
|
||
self._share_lock = threading.Lock()
|
||
self._share_worker_thread: Optional[threading.Thread] = None
|
||
|
||
# ------------------------------------------------------------------
|
||
# 시작/종료
|
||
# ------------------------------------------------------------------
|
||
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()
|
||
|
||
# 영구 구독 (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)
|
||
|
||
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
|
||
|
||
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:
|
||
if code in self._permanent_codes:
|
||
self._enqueue_gap_fill(code)
|
||
else:
|
||
# 후보 종목: 1M 우선 갭보정을 큐 앞쪽에 — BREAKOUT 매수체크 즉시 가능
|
||
self._enqueue_gap_fill(code, priority=True, mode="1m")
|
||
|
||
for code in sorted(kis_want - kis_now):
|
||
self.ws_cache.subscribe(code)
|
||
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)
|
||
# ------------------------------------------------------------------
|
||
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)
|
||
# 신규 구독 → 워커에게 갭보정 위임 (논블로킹)
|
||
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)
|
||
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:
|
||
"""
|
||
한 전략의 관심 종목 목록을 통째로 동기화.
|
||
- 기존 관심 종목 중 없어진 것은 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 []
|
||
|
||
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 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:
|
||
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 fill_gap(self, codes: Optional[Iterable[str]] = None) -> None:
|
||
"""외부에서 수동으로 갭 보정 트리거 (비동기: 큐 등록 후 즉시 리턴)."""
|
||
if codes is None:
|
||
self._trigger_bulk_refill_async()
|
||
else:
|
||
for c in codes:
|
||
self._enqueue_gap_fill(c)
|
||
|
||
# ------------------------------------------------------------------
|
||
# 내부: 갭 보정 — 백그라운드 워커 파이프라인
|
||
# ------------------------------------------------------------------
|
||
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,
|
||
)
|
||
|
||
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 웜업 등)
|
||
mode: ``"1m"`` = 1분봉만 먼저, ``"full"`` = 설정된 전 TF
|
||
"""
|
||
if not code:
|
||
return
|
||
fill_mode = "1m" if str(mode).strip().lower() == "1m" else "full"
|
||
with self._gap_lock:
|
||
if code in self._gap_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")
|
||
only_1m = gap_mode == "1m"
|
||
|
||
# 장중만 실행 (장외면 완료 마커 찍고 다음)
|
||
if not self._is_market_hours() and not get_env_bool("WS_GAP_FILL_OFF_HOURS", False):
|
||
with self._gap_lock:
|
||
self._gap_inflight.discard(code)
|
||
self._gap_mode.pop(code, None)
|
||
self._gap_filled.add(code)
|
||
if from_prio:
|
||
self._gap_prio_q.task_done()
|
||
else:
|
||
self._gap_q.task_done()
|
||
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",
|
||
)
|
||
self._gap_worker_boot_logged = True
|
||
|
||
try:
|
||
only_tfs = {1} if only_1m else None
|
||
ok = self._fill_gap_for_code(
|
||
code,
|
||
kw_key=kw_key, kw_secret=kw_secret, kw_mock=kw_mock,
|
||
only_tfs=only_tfs,
|
||
)
|
||
except Exception as e:
|
||
logger.debug("갭보정 워커 예외 (%s): %s", code, e)
|
||
ok = False
|
||
finally:
|
||
with self._gap_lock:
|
||
self._gap_inflight.discard(code)
|
||
self._gap_mode.pop(code, None)
|
||
|
||
if only_1m:
|
||
# 1M 웜업 성공 → 나머지 TF 는 일반 큐로 이어서
|
||
have_1m = 1 in self._gap_tf_ok.get(code, set())
|
||
if have_1m:
|
||
need = set(self.candle_agg.timeframes)
|
||
have = self._gap_tf_ok.get(code, set())
|
||
if not need.issubset(have):
|
||
threading.Thread(
|
||
target=lambda c=code: self._enqueue_gap_fill(
|
||
c, mode="full",
|
||
),
|
||
daemon=True,
|
||
).start()
|
||
else:
|
||
self._gap_filled.add(code)
|
||
self._gap_retry_count.pop(code, None)
|
||
else:
|
||
retries = self._gap_retry_count.get(code, 0) + 1
|
||
max_retries = get_env_int("WS_GAP_FILL_MAX_RETRIES", 3)
|
||
self._gap_retry_count[code] = retries
|
||
if retries < max_retries:
|
||
delay = float(get_env_int("WS_GAP_FILL_RETRY_DELAY_SEC", 8))
|
||
logger.warning(
|
||
"⚠️ [갭보정] %s 1M 실패 → %ds 후 우선 재시도 (%d/%d)",
|
||
code, int(delay), retries, max_retries,
|
||
)
|
||
threading.Timer(
|
||
delay,
|
||
lambda c=code: self._enqueue_gap_fill(
|
||
c, force=True, priority=True, mode="1m",
|
||
),
|
||
).start()
|
||
else:
|
||
logger.warning(
|
||
"⚠️ [갭보정] %s 1M 최대 재시도 초과 — WS 틱 누적으로 대체",
|
||
code,
|
||
)
|
||
elif ok:
|
||
self._gap_filled.add(code)
|
||
self._gap_retry_count.pop(code, None)
|
||
else:
|
||
retries = self._gap_retry_count.get(code, 0) + 1
|
||
max_retries = get_env_int("WS_GAP_FILL_MAX_RETRIES", 3)
|
||
self._gap_retry_count[code] = retries
|
||
if retries < max_retries:
|
||
delay = float(get_env_int("WS_GAP_FILL_RETRY_DELAY_SEC", 8))
|
||
logger.warning(
|
||
"⚠️ [갭보정] %s 일부 TF 실패 → %ds 후 재시도 (%d/%d)",
|
||
code, int(delay), retries, max_retries,
|
||
)
|
||
threading.Timer(
|
||
delay,
|
||
lambda c=code: self._enqueue_gap_fill(c, force=True),
|
||
).start()
|
||
else:
|
||
logger.warning(
|
||
"⚠️ [갭보정] %s 최대 재시도 초과 — WS 틱 누적으로 대체",
|
||
code,
|
||
)
|
||
self._gap_filled.add(code)
|
||
|
||
if from_prio:
|
||
self._gap_prio_q.task_done()
|
||
else:
|
||
self._gap_q.task_done()
|
||
|
||
# 종목 간 sleep (REST 레이트리밋 완충)
|
||
self._gap_code_sleep()
|
||
|
||
@staticmethod
|
||
def _is_market_hours() -> bool:
|
||
"""
|
||
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 (BREAKOUT·SHORT 핵심)."""
|
||
raw = get_env_from_db("WS_GAP_FILL_PRIORITY_TFS", "1,3")
|
||
return set(self._parse_tf_csv(raw, "1,3"))
|
||
|
||
def _resolve_gap_fill_tf_order(self) -> List[int]:
|
||
"""우선 TF(1M·3M) 먼저, 이후 15M/60M — 레이트리밋 시 핵심 봉 선확보."""
|
||
all_tfs = list(self.candle_agg.timeframes)
|
||
priority = self._gap_priority_tfs()
|
||
ordered: List[int] = [tf for tf in self._parse_tf_csv(
|
||
get_env_from_db("WS_GAP_FILL_PRIORITY_TFS", "1,3"), "1,3",
|
||
) if tf in all_tfs]
|
||
for tf in all_tfs:
|
||
if tf not in priority:
|
||
ordered.append(tf)
|
||
return ordered
|
||
|
||
def _gap_tf_sleep(self) -> None:
|
||
"""TF 간 REST 호출 간격 — 키움 ka10080 레이트리밋 완충."""
|
||
lo = float(get_env_float("WS_GAP_FILL_TF_SLEEP_MIN_SEC", 0.6))
|
||
hi = float(get_env_float("WS_GAP_FILL_TF_SLEEP_MAX_SEC", 1.2))
|
||
if hi < lo:
|
||
lo, hi = hi, lo
|
||
time.sleep(random.uniform(lo, hi))
|
||
|
||
def _gap_code_sleep(self) -> None:
|
||
"""종목 간 REST 호출 간격."""
|
||
lo = float(get_env_float("WS_GAP_FILL_CODE_SLEEP_MIN_SEC", 0.4))
|
||
hi = float(get_env_float("WS_GAP_FILL_CODE_SLEEP_MAX_SEC", 0.8))
|
||
if hi < lo:
|
||
lo, hi = hi, lo
|
||
time.sleep(random.uniform(lo, hi))
|
||
|
||
def _gap_tf_already_ok(self, code: str, tf: int) -> bool:
|
||
with self._gap_lock:
|
||
return tf in self._gap_tf_ok.get(code, set())
|
||
|
||
def _mark_gap_tf_ok(self, code: str, tf: int) -> None:
|
||
with self._gap_lock:
|
||
self._gap_tf_ok.setdefault(code, set()).add(tf)
|
||
|
||
def _all_gap_tfs_ok(self, code: str) -> bool:
|
||
need = set(self.candle_agg.timeframes)
|
||
with self._gap_lock:
|
||
have = self._gap_tf_ok.get(code, set())
|
||
return need.issubset(have)
|
||
|
||
def _fill_gap_for_code(
|
||
self,
|
||
code: str,
|
||
*,
|
||
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._all_gap_tfs_ok(code):
|
||
return True
|
||
|
||
limit = get_env_int("WS_GAP_FILL_LIMIT", 120)
|
||
use_kiwoom = bool(kw_key and kw_secret and get_kiwoom_candles_df is not None)
|
||
kis_fallback_on = get_env_bool("WS_GAP_FILL_KIS_FALLBACK", False)
|
||
priority = self._gap_priority_tfs()
|
||
ordered_tfs = self._resolve_gap_fill_tf_order()
|
||
if only_tfs is not None:
|
||
ordered_tfs = [tf for tf in ordered_tfs if tf in only_tfs]
|
||
phase_pause = float(get_env_float("WS_GAP_FILL_PHASE_PAUSE_SEC", 1.5))
|
||
prev_tf: Optional[int] = None
|
||
|
||
for tf in ordered_tfs:
|
||
if self._gap_tf_already_ok(code, tf):
|
||
prev_tf = tf
|
||
continue
|
||
|
||
# 우선(1M·3M) → 장기(15M·60M) 전환 전 추가 휴식
|
||
if (
|
||
prev_tf is not None
|
||
and prev_tf in priority
|
||
and tf not in priority
|
||
and phase_pause > 0
|
||
):
|
||
logger.debug(
|
||
"[갭보정] %s 우선TF 완료 → %ds pause 후 %dM",
|
||
code, int(phase_pause), tf,
|
||
)
|
||
time.sleep(phase_pause)
|
||
|
||
df = None
|
||
|
||
if use_kiwoom:
|
||
try:
|
||
df = get_kiwoom_candles_df(
|
||
code, tf, kw_key, kw_secret,
|
||
is_mock=kw_mock, n=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=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)
|
||
else:
|
||
logger.warning("⚠️ [갭보정] %s %dM → REST 빈 응답 (재시도 대상)", code, tf)
|
||
|
||
prev_tf = tf
|
||
self._gap_tf_sleep()
|
||
|
||
return self._all_gap_tfs_ok(code) if only_tfs is None else (
|
||
all(tf in self._gap_tf_ok.get(code, set()) for tf in only_tfs)
|
||
)
|
||
|
||
def get_share_denom(self, code: str) -> float:
|
||
"""
|
||
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]
|