WS 매니저 spill·조건검색 CNSR 회복·kiwoom_ws_diag 진단 경로를 보강한다. live_sell_price stale RAM/REST 폴백 정합을 유지한다. Co-authored-by: Cursor <cursoragent@cursor.com>
3136 lines
136 KiB
Python
3136 lines
136 KiB
Python
"""
|
||
kis_trader/network/ws_manager.py — WebSocket 허브 (Event Bus)
|
||
====================================================================
|
||
설계 목적:
|
||
* 전략마다 WS 를 띄우면 같은 종목 중복 구독 → 토큰/approval 경합.
|
||
프로세스에서 증권사별 세션은 **공유 1개씩**(한투·키움·LS). "소켓이 하나"는 아님.
|
||
* ``kis_trader.ws.kis_ws`` — ``KISWebSocketPriceCache`` + ``CandleAggregator`` 재사용.
|
||
* 전략별 "구독 관심 종목" 을 **레퍼런스 카운팅**으로 관리. 한 전략이 구독 해제해도
|
||
다른 전략이 구독 중이면 WS 에서 해제되지 않는다.
|
||
|
||
구독 명단 vs 현재가 읽기 (섞지 말 것):
|
||
* ``WS_SUBSCRIBE_KIS_MINIMAL`` — 한투 소켓에 **누구를 넣을지**.
|
||
true: 한투=보유만, 후보=키움. false: 한투도 후보∪보유(41 한도).
|
||
``LIVE_TICK_PROVIDER`` 를 kis 로 바꿔도 이 명단은 안 바뀜.
|
||
* ``LIVE_TICK_PROVIDER`` — ``get_price()`` 가 **어느 RAM 을 먼저 볼지**.
|
||
MINIMAL=true 이면 한투 RAM 은 보유만 채워짐. 후보 현재가는 한투에 없음 → 키움 폴백.
|
||
|
||
공용 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) : ``WS_SUBSCRIBE_KIS_MINIMAL`` 시
|
||
후보=키움, 한투=보유만. 영구구독 KR은 LS (한투 41에 안 넣음)
|
||
- 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
|
||
|
||
# get_price() 인자 생략 시 env 기본. None 은 마지막 RAM(나이 무시).
|
||
_WS_PRICE_AGE_OMITTED = object()
|
||
|
||
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,
|
||
fetch_kiwoom_cur_prc_ka10007,
|
||
)
|
||
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]
|
||
fetch_kiwoom_cur_prc_ka10007 = 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)
|
||
_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]
|
||
fetch_kiwoom_cur_prc_ka10007 = None # type: ignore[assignment]
|
||
|
||
|
||
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.kis_ws_ob: 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
|
||
# WS_SUBSCRIBE_KIS_MINIMAL: 후보=키움, 한투=보유만(세션 41). 영구 KR은 LS.
|
||
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). 한투 41·영구(LS)와 별개.
|
||
self._grace_until: Dict[str, float] = {}
|
||
# 보유 이탈 후 KIS 41슬롯 유지 (만료 epoch). 후보는 안 넣음.
|
||
self._kis_hold_grace_until: Dict[str, float] = {}
|
||
# grace 1회 소진 후 재연장 방지 (재진입 시 discard)
|
||
self._grace_exhausted: Set[str] = set()
|
||
self._lock = threading.Lock()
|
||
# split reconcile 본문 — WS-ReconcileWorker 단독 (전략 루프는 lock 안 잡음)
|
||
self._reconcile_lock = threading.Lock()
|
||
self._reconcile_event = threading.Event()
|
||
self._reconcile_worker_thread: Optional[threading.Thread] = None
|
||
self._reconcile_ls_feed_pending = False
|
||
# 갭보정 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
|
||
|
||
# ── 구독 spill home (한도/실패/미연결 시 벤더 체인) ─────────────
|
||
# code → kis|kiwoom|ls . 정상 경로도 기록해 MM 시세 표기·읽기 우선에 사용.
|
||
self._tick_home: Dict[str, str] = {}
|
||
self._ob_home: Dict[str, str] = {}
|
||
self._tick_home_spill: Set[str] = set() # spill로 잡힌 코드 (MM 표기용)
|
||
self._ob_home_spill: Set[str] = set()
|
||
# get_price / get_orderbook_snapshot 이 실제로 쓴 벤더 (MM 시세|호가 줄)
|
||
self._last_tick_read: Dict[str, Dict[str, Any]] = {}
|
||
self._last_ob_read: Dict[str, Dict[str, Any]] = {}
|
||
self._feed_read_lock = threading.Lock()
|
||
|
||
# ------------------------------------------------------------------
|
||
# 시작/종료
|
||
# ------------------------------------------------------------------
|
||
def start(self) -> bool:
|
||
"""WS 세션 시작. 실패 시 False (봇은 REST 폴백으로 동작)."""
|
||
if not _KIS_WS_AVAILABLE:
|
||
logger.warning("kis_ws 미설치 → WS 허브 비활성 (REST 폴백만 동작)")
|
||
return False
|
||
|
||
# ── [중요] WS 는 데이터 수신용이므로 무조건 실전 서버로 접속 ──
|
||
# kis_scalping_ver2 와 동일 정책 (모의 계좌라도 시세는 실전 필요)
|
||
is_mock = get_env_bool("KIS_MOCK", True)
|
||
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)
|
||
# 메인 세션은 체결(H0STCNT0)만. 호가(H0STASP0)는 2번째 키 전용 세션.
|
||
self.ws_cache._ws_role = "tick"
|
||
|
||
if get_env_bool("WS_TICK_SAVE_KIS", True) or get_env_bool("WS_TICK_SAVE_KIWOOM", True):
|
||
try:
|
||
self.tick_recorder = TickRecorder(db=self.db)
|
||
if get_env_bool("WS_TICK_SAVE_KIS", True):
|
||
self.ws_cache.attach_tick_recorder(self.tick_recorder)
|
||
except Exception as tr_ex:
|
||
logger.warning("TickRecorder 기동 실패 (봉 집계만 동작): %s", tr_ex)
|
||
self.tick_recorder = None
|
||
|
||
# KIS 2번째 호가 전용 웹소켓 기동
|
||
# 호가도 시세이므로 실전 키 우선 (틱 WS와 동일). 실전 미입력이면 모의 호가키 폴백.
|
||
ob_key = (get_env_from_db("KIS_APP_KEY_OB_REAL", "") or "").strip()
|
||
ob_secret = (get_env_from_db("KIS_APP_SECRET_OB_REAL", "") or "").strip()
|
||
ob_is_mock = False
|
||
if not ob_key or not ob_secret:
|
||
ob_key = (get_env_from_db("KIS_APP_KEY_OB_MOCK", "") or "").strip()
|
||
ob_secret = (get_env_from_db("KIS_APP_SECRET_OB_MOCK", "") or "").strip()
|
||
ob_is_mock = bool(ob_key and ob_secret)
|
||
|
||
if ob_key and ob_secret:
|
||
try:
|
||
logger.info(
|
||
"🔑 KIS 호가 전용(OB) WS 자격증명 로드됨 (ob_mock=%s, kis_mock=%s) -> 2nd WS 연결 시도",
|
||
ob_is_mock, is_mock,
|
||
)
|
||
self.kis_ws_ob = KISWebSocketPriceCache(
|
||
ob_key,
|
||
ob_secret,
|
||
is_mock=ob_is_mock,
|
||
approval_slot="ob",
|
||
)
|
||
if not self.kis_ws_ob.start():
|
||
logger.warning(
|
||
"KIS 호가 전용 WS(OB) 시작 실패 → 메인 세션에 ASP0 안 붙임 "
|
||
"(2키 전용. 시세 41 합산 금지)"
|
||
)
|
||
self.kis_ws_ob = None
|
||
else:
|
||
self.kis_ws_ob._ws_role = "orderbook"
|
||
logger.info("✅ KIS 호가 전용 WS(OB) 정상 시작 완료 (main=tick, ob=orderbook)")
|
||
except Exception as e:
|
||
logger.warning("KIS 호가 전용 WS(OB) 생성 중 오류: %s → 메인 ASP0 폴백 없음", e)
|
||
self.kis_ws_ob = None
|
||
else:
|
||
logger.warning(
|
||
"KIS 호가 전용 키 없음(KIS_APP_KEY_OB_REAL/MOCK) → "
|
||
"kis_ws_ob=None, 메인 H0STASP0 안 붙임"
|
||
)
|
||
self.kis_ws_ob = 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_KIWOOM", True) or get_env_bool("WS_ORDERBOOK_SAVE_KIS", 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
|
||
# Orderbook Snapshot Recorder 부착 (호가용, kis_ws_ob 기준)
|
||
# attach 실패해도 recorder 자체는 유지 (키움 main 쪽 부착용)
|
||
if (
|
||
self.trigger_snapshot_recorder is not None
|
||
and get_env_bool("WS_ORDERBOOK_SAVE_KIS", False)
|
||
and self.kis_ws_ob is not None
|
||
):
|
||
try:
|
||
self.kis_ws_ob.attach_trigger_snapshot_recorder(self.trigger_snapshot_recorder)
|
||
logger.info("✅ KIS 호가 WS(OB)에 TriggerSnapshotRecorder 부착 완료 (KIS 호가 → kis_ws_orderbook 적재)")
|
||
except Exception as att_ex:
|
||
logger.warning("KIS OB TriggerSnapshotRecorder 부착 실패: %s", att_ex)
|
||
elif get_env_bool("WS_ORDERBOOK_SAVE_KIS", False) and self.kis_ws_ob is None:
|
||
logger.warning(
|
||
"WS_ORDERBOOK_SAVE_KIS=ON 이나 2키 OB 세션 없음 → 적재 생략"
|
||
)
|
||
# 메인 틱 세션: 체결(H0STCNT0)마다 2키 OB RAM 틱동기 DB (키움 0B↔0D 와 동일)
|
||
if (
|
||
self.trigger_snapshot_recorder is not None
|
||
and self.ws_cache is not None
|
||
and get_env_bool("WS_ORDERBOOK_SAVE_KIS", False)
|
||
):
|
||
try:
|
||
self.ws_cache.attach_trigger_snapshot_recorder(self.trigger_snapshot_recorder)
|
||
if self.kis_ws_ob is not None and self.kis_ws_ob is not self.ws_cache:
|
||
self.ws_cache.attach_orderbook_peer(self.kis_ws_ob)
|
||
logger.info(
|
||
"✅ KIS 메인 tick→OB peer 틱동기 부착 (H0STCNT0→H0STASP0 RAM→ws_orderbook)"
|
||
)
|
||
except Exception as peer_ex:
|
||
logger.warning("KIS tick↔OB 틱동기 부착 실패: %s", peer_ex)
|
||
|
||
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()
|
||
self._start_reconcile_worker()
|
||
|
||
# 영구 구독 (KOSPI/KOSDAQ ETF 등) — KR 은 LS WS. KIS 슬롯·ws_candles 갭 제외
|
||
self._load_permanent_codes()
|
||
try:
|
||
self._sync_permanent_to_ls(set(self._permanent_codes))
|
||
except Exception:
|
||
pass
|
||
with self._lock:
|
||
self._gap_refill_codes = set()
|
||
|
||
# 연결 성공 후 자동 갭 보정 등록 (WS 재접속 시 전체 재갭보정)
|
||
self.ws_cache.set_on_connected_callback(self._trigger_bulk_refill_async)
|
||
self._reattach_all_price_listeners()
|
||
try:
|
||
if self.candle_agg is not None:
|
||
if self.tick_recorder is not None:
|
||
self.candle_agg.set_tick_lookup(
|
||
lambda c: self.tick_recorder.get_recent_ticks(c, limit=8000)
|
||
if self.tick_recorder else []
|
||
)
|
||
self.candle_agg.set_ls_bars_fn(self._ls_confirmed_bars_ram)
|
||
except Exception:
|
||
pass
|
||
|
||
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.kis_ws_ob is not None and self.kis_ws_ob is not self.ws_cache:
|
||
self.kis_ws_ob.stop(clear_subscriptions=True)
|
||
except Exception as e:
|
||
logger.debug("KIS OB WS stop 실패: %s", e)
|
||
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)
|
||
|
||
# ------------------------------------------------------------------
|
||
# 키움 분리 시세 (MINIMAL: 한투=보유, 후보=키움, 영구KR=LS) — Orchestrator 가 주입
|
||
# ------------------------------------------------------------------
|
||
def set_kiwoom_ws(self, kiwoom_ws: Any) -> None:
|
||
"""키움 WS 인스턴스 (기동 후 주입). ``activate_split_feed`` 전에 설정."""
|
||
self._kiwoom_ws = kiwoom_ws
|
||
self._reattach_all_price_listeners()
|
||
|
||
if not self._split_feed_active:
|
||
with self._lock:
|
||
active_codes = list(self._code_refs.keys())
|
||
if active_codes:
|
||
self.logger.info("🔄 [WSManager] 키움 WS 사후 주입 → 기존 KIS 구독 %d종목 키움에도 동기화", len(active_codes))
|
||
try:
|
||
self._kiwoom_ws.subscribe_many(active_codes)
|
||
except Exception:
|
||
for code in active_codes:
|
||
self._kiwoom_ws.subscribe(code)
|
||
|
||
def activate_split_feed(self, active: bool) -> None:
|
||
"""``WS_SUBSCRIBE_KIS_MINIMAL`` + 키움 준비 완료 후 True → 후보=키움, 한투=보유만.
|
||
|
||
``LIVE_TICK_PROVIDER`` 와 무관. 메인을 한투로 바꿔도 후보 구독은 키움.
|
||
영구구독 KR은 LS. 한투 41은 영구 슬롯이 아님.
|
||
"""
|
||
self._split_feed_active = bool(active and self._kiwoom_ws and self.ws_cache)
|
||
if self._split_feed_active:
|
||
logger.info("✅ WS 분리 시세 활성: 한투=보유만, 후보=키움, 영구KR=LS")
|
||
|
||
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}
|
||
left_hold: Set[str] = set()
|
||
with self._lock:
|
||
prev_hold = set(self._owner_holdings.get(owner) or set())
|
||
left_hold = prev_hold - hold
|
||
if ls_feed:
|
||
self._ls_feed_owners[owner] = set(cand | hold)
|
||
else:
|
||
self._ls_feed_owners.pop(owner, None)
|
||
# MINIMAL OFF 레거시 sync 에도 보유 pin 이 필요
|
||
self._owner_candidates[owner] = cand
|
||
self._owner_holdings[owner] = hold
|
||
for code in left_hold:
|
||
if code in cand or code in hold:
|
||
continue
|
||
self._arm_kis_hold_grace(code)
|
||
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
|
||
# REG/호가/갭 enqueue 는 전략 루프 밖 단일 워커 (동시 4전략 lock 대기 금지)
|
||
self._schedule_reconcile_split(include_ls_feed=True)
|
||
|
||
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 _sync_permanent_to_ls(self, perm: "Set[str]") -> None:
|
||
"""영구구독 코드를 LS WS에 sync — KIS/키움 슬롯 절약.
|
||
마스터 OFF면 빈 집합 sync → 구독만 해제(행 유지).
|
||
"""
|
||
try:
|
||
import permanent_subs as _ps
|
||
if not _ps.subscribe_master_enabled():
|
||
perm = set()
|
||
except Exception:
|
||
pass
|
||
ls_ws = self._get_ls_ws()
|
||
if ls_ws is None:
|
||
return
|
||
try:
|
||
ls_ws.sync_owner_codes("_permanent", perm or set())
|
||
except Exception as e:
|
||
logger.debug("LS permanent sync 실패: %s", e)
|
||
|
||
def _sync_feed_fallback_to_ls(self, codes: "Set[str]") -> None:
|
||
"""(레거시) 메인 유니버스 전체 미러 — 기본 OFF. spill/permanent 경로만 사용.
|
||
|
||
owner=_feed_fallback. ``LS_FEED_FALLBACK_SUBSCRIBE=false`` 이면 항상 빈 집합 sync.
|
||
"""
|
||
if not get_env_bool("LS_FEED_FALLBACK_SUBSCRIBE", False):
|
||
codes = set()
|
||
ls_ws = self._get_ls_ws()
|
||
if ls_ws is None:
|
||
return
|
||
try:
|
||
ls_ws.sync_owner_codes("_feed_fallback", codes or set())
|
||
except Exception as e:
|
||
logger.debug("LS feed-fallback sync 실패: %s", e)
|
||
|
||
def _bind_ls_ws_hooks(self) -> None:
|
||
"""LS hold RAM 유지·headroom 등 WSManager 연동."""
|
||
ls_ws = self._get_ls_ws()
|
||
if ls_ws is None:
|
||
return
|
||
if getattr(ls_ws, "_ws_manager_bound", False):
|
||
return
|
||
ls_ws._hold_ram_extra_codes_fn = self._ls_hold_ram_extra_codes
|
||
ls_ws._ws_manager_bound = True
|
||
|
||
def _ls_hold_ram_extra_codes(self) -> Set[str]:
|
||
"""hold reopen 시 RAM 유지: 영구 + (옵션) 보유."""
|
||
with self._lock:
|
||
out = set(self._permanent_codes)
|
||
if get_env_bool("LS_WS_HOLD_RAM_KEEP_HOLDINGS", True):
|
||
out |= self._all_holdings()
|
||
return {str(c).strip() for c in out if str(c).strip()}
|
||
|
||
def _ls_ephemeral_allowed_codes(
|
||
self,
|
||
kw_want: "Set[str]",
|
||
perm: "Set[str]",
|
||
) -> Set[str]:
|
||
"""LS spill/_feed_fallback 이 유지할 수 있는 종목 (grace·ls_condition 포함)."""
|
||
allowed = set(kw_want) | set(perm)
|
||
grace = self._purge_and_get_grace_codes()
|
||
if grace:
|
||
allowed |= set(grace)
|
||
with self._lock:
|
||
allowed |= self._pure_ls_codes_locked()
|
||
return {str(c).strip() for c in allowed if str(c).strip()}
|
||
|
||
def _prune_ls_ephemeral_subscriptions(
|
||
self,
|
||
kw_want: "Set[str]",
|
||
perm: "Set[str]",
|
||
) -> None:
|
||
"""LS spill·_feed_fallback 정리 — KIS/키움 want/grace와 동일 수명."""
|
||
self._bind_ls_ws_hooks()
|
||
allowed = self._ls_ephemeral_allowed_codes(kw_want, perm)
|
||
if get_env_bool("LS_FEED_FALLBACK_SUBSCRIBE", False):
|
||
self._sync_feed_fallback_to_ls(self._ls_ram_universe_codes())
|
||
else:
|
||
self._sync_feed_fallback_to_ls(set())
|
||
ls = self._get_ls_ws()
|
||
if ls is None:
|
||
return
|
||
spill_drop: List[str] = []
|
||
with ls._sub_lock:
|
||
for code, ow in list(ls._sub_owners.items()):
|
||
if "spill" not in ow:
|
||
continue
|
||
if code not in allowed:
|
||
spill_drop.append(code)
|
||
for code in spill_drop:
|
||
try:
|
||
ls.unsubscribe(code, owner="spill")
|
||
except Exception:
|
||
pass
|
||
|
||
def _finalize_ls_subscriptions(
|
||
self,
|
||
kw_want: "Set[str]",
|
||
perm: "Set[str]",
|
||
) -> None:
|
||
"""reconcile/sync 끝: 영구 sync + spill-only 정리."""
|
||
self._sync_permanent_to_ls(perm)
|
||
self._prune_ls_ephemeral_subscriptions(kw_want, perm)
|
||
|
||
def _ensure_ls_headroom_for_new(self, need: int, allowed: Set[str]) -> None:
|
||
"""LS 로컬 한도(기본 100) − headroom. grace 밖 spill 부터 해제."""
|
||
if need <= 0:
|
||
return
|
||
ls = self._get_ls_ws()
|
||
if ls is None:
|
||
return
|
||
try:
|
||
limit = int(get_env_int("LS_WS_MAX_SUBSCRIPTIONS", 100) or 0)
|
||
except Exception:
|
||
limit = 100
|
||
if limit <= 0:
|
||
return
|
||
headroom = self._ws_grace_headroom()
|
||
soft_cap = max(1, limit - headroom)
|
||
with ls._sub_lock:
|
||
n_now = len(ls._subscribed) + len(getattr(ls, "_us_subscribed", set()))
|
||
free = soft_cap - n_now
|
||
if free >= need:
|
||
return
|
||
drop_n = need - max(0, free)
|
||
with ls._sub_lock:
|
||
candidates = [
|
||
c for c in ls._subscribed
|
||
if "spill" in (ls._sub_owners.get(c) or set())
|
||
and c not in allowed
|
||
]
|
||
dropped = 0
|
||
for code in candidates:
|
||
if dropped >= drop_n:
|
||
break
|
||
try:
|
||
ls.unsubscribe(code, owner="spill")
|
||
except Exception:
|
||
pass
|
||
dropped += 1
|
||
|
||
def _ls_ram_universe_codes(self) -> Set[str]:
|
||
"""LS RAM 미러 대상: 후보∪보유∪영구∪grace. MINIMAL 여부와 무관."""
|
||
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
|
||
out = cand_u | hold_u | perm
|
||
else:
|
||
out = set(self._code_refs.keys()) | perm
|
||
grace = self._purge_and_get_grace_codes()
|
||
if grace:
|
||
out |= set(grace)
|
||
return {str(c).strip() for c in out if str(c).strip()}
|
||
|
||
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
|
||
# 후보 갭 REST 기본 OFF — 폴백 구독만. 영구는 기존 경로.
|
||
if not get_env_bool("LS_GAP_FILL_CANDIDATES", False):
|
||
with self._lock:
|
||
perm = set(self._permanent_codes)
|
||
if str(code).strip() not in perm:
|
||
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)
|
||
|
||
# ------------------------------------------------------------------
|
||
# 구독 spill (한도초과·구독실패·WS 미연결 → 즉시 다음 벤더, sleep 없음)
|
||
# ------------------------------------------------------------------
|
||
def _subscribe_spill_enabled(self) -> bool:
|
||
return bool(get_env_bool("WS_SUBSCRIBE_SPILL", True))
|
||
|
||
def _normalize_feed_vendor(self, raw: str, default: str = "kiwoom") -> str:
|
||
v = (raw or default).strip().lower()
|
||
if v in ("kis", "kiwoom", "ls", "kiwoom_rest"):
|
||
return v
|
||
if v in ("ls_condition", "ls_ws", "ls_afr"):
|
||
return "ls"
|
||
return default
|
||
|
||
def _build_subscribe_chain(self, kind: str) -> List[str]:
|
||
"""틱/호가 구독 체인. 1차=LIVE_* , 기본 나머지 kiwoom/kis 후 ls(3차)."""
|
||
kind = (kind or "tick").strip().lower()
|
||
if kind == "ob":
|
||
chain_raw = (get_env_from_db("WS_OB_SUBSCRIBE_CHAIN", "") or "").strip()
|
||
primary = self._normalize_feed_vendor(
|
||
get_env_from_db("LIVE_OB_PROVIDER", "kiwoom") or "kiwoom",
|
||
"kiwoom",
|
||
)
|
||
else:
|
||
chain_raw = (get_env_from_db("WS_TICK_SUBSCRIBE_CHAIN", "") or "").strip()
|
||
primary = self._normalize_feed_vendor(
|
||
get_env_from_db("LIVE_TICK_PROVIDER", "kiwoom") or "kiwoom",
|
||
"kiwoom",
|
||
)
|
||
if chain_raw:
|
||
out: List[str] = []
|
||
for part in chain_raw.split(","):
|
||
v = self._normalize_feed_vendor(part, "")
|
||
if v and v not in out:
|
||
out.append(v)
|
||
return out or [primary, "ls"]
|
||
rest = [x for x in ("kiwoom", "kis", "ls") if x != primary]
|
||
# ls 는 항상 마지막(3차)
|
||
mid = [x for x in rest if x != "ls"]
|
||
out = [primary] + mid
|
||
if "ls" not in out:
|
||
out.append("ls")
|
||
return out
|
||
|
||
def _vendor_object_present(self, vendor: str) -> bool:
|
||
v = self._normalize_feed_vendor(vendor, "")
|
||
if v == "kis":
|
||
return self.ws_cache is not None
|
||
if v == "kiwoom":
|
||
return self._kiwoom_ws is not None
|
||
if v == "ls":
|
||
return self._get_ls_ws() is not None
|
||
return False
|
||
|
||
def _vendor_live_for_spill(self, vendor: str) -> bool:
|
||
"""spill 대상은 이미 연결된 세션만 (대기 금지)."""
|
||
v = self._normalize_feed_vendor(vendor, "")
|
||
if v == "kis":
|
||
return bool(self.ws_cache and getattr(self.ws_cache, "is_active", False))
|
||
if v == "kiwoom":
|
||
return bool(self._kiwoom_ws and self._kiwoom_ws.is_connected())
|
||
if v == "ls":
|
||
ls = self._get_ls_ws()
|
||
return bool(ls is not None and ls.is_connected())
|
||
return False
|
||
|
||
def _code_subscribed_on(self, vendor: str, code: str) -> bool:
|
||
v = self._normalize_feed_vendor(vendor, "")
|
||
code = (code or "").strip()
|
||
if not code:
|
||
return False
|
||
try:
|
||
if v == "kis" and self.ws_cache is not None:
|
||
with self.ws_cache._sub_lock:
|
||
return code in self.ws_cache._subscribed
|
||
if v == "kiwoom" and self._kiwoom_ws is not None:
|
||
with self._kiwoom_ws._sub_lock:
|
||
return code in self._kiwoom_ws._subscribed
|
||
if v == "ls":
|
||
ls = self._get_ls_ws()
|
||
if ls is None:
|
||
return False
|
||
with ls._sub_lock:
|
||
return code in ls._subscribed or code in getattr(ls, "_us_subscribed", set())
|
||
except Exception:
|
||
return False
|
||
return False
|
||
|
||
def _try_subscribe_vendor_tick(self, vendor: str, code: str, *, spill: bool) -> bool:
|
||
"""벤더에 틱 구독 시도. spill=True 이면 LS는 owner=spill(RAM), recorder 미부착."""
|
||
v = self._normalize_feed_vendor(vendor, "")
|
||
code = (code or "").strip()
|
||
if not code or not v:
|
||
return False
|
||
if self._code_subscribed_on(v, code):
|
||
return True
|
||
if spill and not self._vendor_live_for_spill(v):
|
||
return False
|
||
if not spill and not self._vendor_object_present(v):
|
||
return False
|
||
try:
|
||
if v == "kis":
|
||
if self.ws_cache is None:
|
||
return False
|
||
return bool(self.ws_cache.subscribe(code))
|
||
if v == "kiwoom":
|
||
if self._kiwoom_ws is None:
|
||
return False
|
||
return bool(self._kiwoom_ws.subscribe(code))
|
||
if v == "ls":
|
||
ls = self._get_ls_ws()
|
||
if ls is None:
|
||
return False
|
||
self._bind_ls_ws_hooks()
|
||
if spill:
|
||
with self._lock:
|
||
perm = set(self._permanent_codes)
|
||
kw_want: Set[str] = set()
|
||
if self._split_feed_active:
|
||
for s in self._owner_candidates.values():
|
||
kw_want |= s
|
||
for s in self._owner_holdings.values():
|
||
kw_want |= s
|
||
else:
|
||
kw_want = set(self._code_refs.keys())
|
||
allowed = self._ls_ephemeral_allowed_codes(kw_want, perm)
|
||
self._ensure_ls_headroom_for_new(1, allowed)
|
||
# spill 경로: DB recorder 붙이지 않음 — RAM만 (기존 LS 적재 설정 변경 없음)
|
||
owner = "spill" if spill else "default"
|
||
return bool(ls.subscribe(code, owner=owner))
|
||
except Exception as e:
|
||
logger.debug("subscribe %s %s 실패: %s", v, code, e)
|
||
return False
|
||
return False
|
||
|
||
def _try_subscribe_vendor_ob(self, vendor: str, code: str, *, spill: bool) -> bool:
|
||
"""호가 구독. KIS는 2키 kis_ws_ob(역할 orderbook)만. 메인 ASP0 합산 금지."""
|
||
v = self._normalize_feed_vendor(vendor, "")
|
||
code = (code or "").strip()
|
||
if not code or not v:
|
||
return False
|
||
if spill and not self._vendor_live_for_spill(v):
|
||
return False
|
||
try:
|
||
if v == "kiwoom":
|
||
# 키움 REG 에 호가 포함 — 틱 구독과 동일 세션
|
||
if self._code_subscribed_on("kiwoom", code):
|
||
return True
|
||
return self._try_subscribe_vendor_tick("kiwoom", code, spill=spill)
|
||
if v == "kis":
|
||
ob = self.kis_ws_ob
|
||
if ob is None or ob is self.ws_cache:
|
||
return False
|
||
if spill and not getattr(ob, "is_active", False):
|
||
return False
|
||
with ob._sub_lock:
|
||
if code in ob._subscribed:
|
||
return True
|
||
return bool(ob.subscribe(code))
|
||
if v == "ls":
|
||
return self._try_subscribe_vendor_tick("ls", code, spill=spill)
|
||
except Exception as e:
|
||
logger.debug("ob subscribe %s %s 실패: %s", v, code, e)
|
||
return False
|
||
return False
|
||
|
||
def _mark_tick_home(self, code: str, vendor: str, *, spilled: bool, reason: str = "") -> None:
|
||
code = (code or "").strip()
|
||
v = self._normalize_feed_vendor(vendor, "")
|
||
if not code or not v:
|
||
return
|
||
with self._lock:
|
||
self._tick_home[code] = v
|
||
if spilled:
|
||
self._tick_home_spill.add(code)
|
||
else:
|
||
self._tick_home_spill.discard(code)
|
||
if spilled:
|
||
logger.info(
|
||
"📡 [spill tick] %s → %s reason=%s",
|
||
code, v, reason or "fail",
|
||
)
|
||
|
||
def _mark_ob_home(self, code: str, vendor: str, *, spilled: bool, reason: str = "") -> None:
|
||
code = (code or "").strip()
|
||
v = self._normalize_feed_vendor(vendor, "")
|
||
if not code or not v:
|
||
return
|
||
with self._lock:
|
||
self._ob_home[code] = v
|
||
if spilled:
|
||
self._ob_home_spill.add(code)
|
||
else:
|
||
self._ob_home_spill.discard(code)
|
||
if spilled:
|
||
logger.info(
|
||
"📡 [spill ob] %s → %s reason=%s",
|
||
code, v, reason or "fail",
|
||
)
|
||
|
||
def _subscribe_tick_prefer_or_spill(self, code: str, prefer: str) -> Optional[str]:
|
||
"""prefer 벤더 우선 구독. 실패/한도/없음이면 즉시 체인 spill. home vendor 반환."""
|
||
code = (code or "").strip()
|
||
prefer = self._normalize_feed_vendor(prefer, "kiwoom")
|
||
if not code:
|
||
return None
|
||
with self._lock:
|
||
cur = self._tick_home.get(code)
|
||
if cur and self._code_subscribed_on(cur, code):
|
||
return cur
|
||
|
||
# 1차 prefer (기동 전 큐잉 허용 — object present)
|
||
if self._vendor_object_present(prefer):
|
||
if self._try_subscribe_vendor_tick(prefer, code, spill=False):
|
||
self._mark_tick_home(code, prefer, spilled=False)
|
||
return prefer
|
||
reason = "limit"
|
||
else:
|
||
reason = "down"
|
||
|
||
if not self._subscribe_spill_enabled():
|
||
return None
|
||
|
||
chain = self._build_subscribe_chain("tick")
|
||
# prefer 를 맨 앞으로 재배치
|
||
ordered = [prefer] + [v for v in chain if v != prefer]
|
||
for v in ordered:
|
||
if v == prefer:
|
||
continue
|
||
if self._try_subscribe_vendor_tick(v, code, spill=True):
|
||
self._mark_tick_home(code, v, spilled=True, reason=reason)
|
||
# LS spill 시 호가도 RAM 동시
|
||
if v == "ls":
|
||
self._mark_ob_home(code, "ls", spilled=True, reason=reason)
|
||
elif v == "kiwoom":
|
||
self._mark_ob_home(code, "kiwoom", spilled=True, reason=reason)
|
||
return v
|
||
logger.warning("⚠️ [spill tick] %s 최종 거절 (prefer=%s reason=%s)", code, prefer, reason)
|
||
return None
|
||
|
||
def _subscribe_ob_prefer_or_spill(self, code: str, prefer: str) -> Optional[str]:
|
||
code = (code or "").strip()
|
||
prefer = self._normalize_feed_vendor(prefer, "kiwoom")
|
||
if not code:
|
||
return None
|
||
with self._lock:
|
||
cur = self._ob_home.get(code)
|
||
# 이미 prefer 벤더에 붙어 있으면 재전송 스킵.
|
||
# cur≠prefer(예: 키움 틱 선마킹)면 LIVE_OB 1차로 업그레이드 시도.
|
||
if cur and cur == prefer:
|
||
if cur == "kis":
|
||
ob = self.kis_ws_ob
|
||
if ob is not None and ob is not self.ws_cache:
|
||
try:
|
||
with ob._sub_lock:
|
||
if code in ob._subscribed:
|
||
return cur
|
||
except Exception:
|
||
pass
|
||
elif self._code_subscribed_on(cur, code):
|
||
return cur
|
||
|
||
if self._vendor_object_present(prefer) or (
|
||
prefer == "kis" and self.kis_ws_ob is not None and self.kis_ws_ob is not self.ws_cache
|
||
):
|
||
if self._try_subscribe_vendor_ob(prefer, code, spill=False):
|
||
self._mark_ob_home(code, prefer, spilled=False)
|
||
return prefer
|
||
reason = "limit"
|
||
else:
|
||
reason = "down"
|
||
|
||
if not self._subscribe_spill_enabled():
|
||
return None
|
||
|
||
chain = self._build_subscribe_chain("ob")
|
||
ordered = [prefer] + [v for v in chain if v != prefer]
|
||
for v in ordered:
|
||
if v == prefer:
|
||
continue
|
||
if self._try_subscribe_vendor_ob(v, code, spill=True):
|
||
self._mark_ob_home(code, v, spilled=True, reason=reason)
|
||
return v
|
||
logger.warning("⚠️ [spill ob] %s 최종 거절 (prefer=%s reason=%s)", code, prefer, reason)
|
||
return None
|
||
|
||
def _remember_feed_read(
|
||
self, kind: str, code: str, vendor: str, *, spilled: bool = False,
|
||
meta: Optional[dict] = None,
|
||
) -> None:
|
||
code = (code or "").strip()
|
||
v = self._normalize_feed_vendor(vendor, vendor or "")
|
||
if not code or not v:
|
||
return
|
||
try:
|
||
from kis_trader.engine.feed_fallback import tick_feed_tier, format_vendor_label
|
||
primary = (
|
||
self._normalize_feed_vendor(
|
||
get_env_from_db("LIVE_OB_PROVIDER", "kiwoom") or "kiwoom",
|
||
"kiwoom",
|
||
)
|
||
if kind == "ob"
|
||
else self._normalize_feed_vendor(
|
||
get_env_from_db("LIVE_TICK_PROVIDER", "kiwoom") or "kiwoom",
|
||
"kiwoom",
|
||
)
|
||
)
|
||
tier = tick_feed_tier(v, primary)
|
||
rec = {
|
||
"vendor": v,
|
||
"tier": tier,
|
||
"spilled": bool(spilled),
|
||
"label": format_vendor_label(v, tier, spilled),
|
||
"ts": time.time(),
|
||
}
|
||
except Exception:
|
||
rec = {"vendor": v, "tier": 0, "spilled": bool(spilled), "label": v, "ts": time.time()}
|
||
if isinstance(meta, dict):
|
||
for k, val in meta.items():
|
||
if k in ("vendor", "tier", "spilled", "label", "ts"):
|
||
continue
|
||
if val is None or val == "":
|
||
continue
|
||
rec[k] = val
|
||
with self._feed_read_lock:
|
||
if kind == "ob":
|
||
self._last_ob_read[code] = rec
|
||
else:
|
||
self._last_tick_read[code] = rec
|
||
|
||
def _label_from_read(self, rec: Optional[dict], *, kind: str, code: str) -> str:
|
||
if rec and rec.get("label"):
|
||
return str(rec.get("label") or "")
|
||
try:
|
||
from kis_trader.engine.feed_fallback import format_vendor_label, tick_feed_tier
|
||
except Exception:
|
||
format_vendor_label = None # type: ignore[assignment]
|
||
tick_feed_tier = None # type: ignore[assignment]
|
||
with self._lock:
|
||
home = self._ob_home.get(code) if kind == "ob" else self._tick_home.get(code)
|
||
spilled = (
|
||
code in self._ob_home_spill if kind == "ob"
|
||
else code in self._tick_home_spill
|
||
)
|
||
primary = self._normalize_feed_vendor(
|
||
get_env_from_db(
|
||
"LIVE_OB_PROVIDER" if kind == "ob" else "LIVE_TICK_PROVIDER",
|
||
"kiwoom",
|
||
) or "kiwoom",
|
||
"kiwoom",
|
||
)
|
||
if home:
|
||
if format_vendor_label is not None and tick_feed_tier is not None:
|
||
return format_vendor_label(home, tick_feed_tier(home, primary), spilled)
|
||
return f"{home}(spill)" if spilled else str(home)
|
||
if format_vendor_label is not None and tick_feed_tier is not None:
|
||
return format_vendor_label(primary, tick_feed_tier(primary, primary), False)
|
||
return primary
|
||
|
||
def get_tick_feed_label(self, code: str) -> str:
|
||
"""매수/매도 MM 표기용 — 예: kiwoom(1차) / kis(2차,spill) / ls(3차)."""
|
||
code = (code or "").strip()
|
||
with self._feed_read_lock:
|
||
rec = dict(self._last_tick_read.get(code) or {})
|
||
return self._label_from_read(rec, kind="tick", code=code)
|
||
|
||
def get_ob_feed_label(self, code: str) -> str:
|
||
"""매수/매도 MM 표기용 호가 소스."""
|
||
code = (code or "").strip()
|
||
with self._feed_read_lock:
|
||
rec = dict(self._last_ob_read.get(code) or {})
|
||
return self._label_from_read(rec, kind="ob", code=code)
|
||
|
||
def get_trigger_feed_trace(self, code: str) -> str:
|
||
"""매수체크 로그용 — 1차설정·실제 벤더·현재가·틱타임·호가 한 줄.
|
||
|
||
조기탈락이 get_price/get_orderbook 을 안 타면 _last_*_read 가 비어
|
||
「틱실제=없음」이 과대계상됨 → 상세로그 ON 일 때 RAM 1회 프로브.
|
||
"""
|
||
code = (code or "").strip()
|
||
if not code:
|
||
return ""
|
||
try:
|
||
from kis_trader.engine.feed_fallback import (
|
||
format_trigger_feed_trace,
|
||
live_ob_primary,
|
||
live_tick_primary,
|
||
trigger_feed_detail_log_enabled,
|
||
vendor_read_max_age_sec,
|
||
)
|
||
if not trigger_feed_detail_log_enabled():
|
||
return ""
|
||
with self._feed_read_lock:
|
||
tick = dict(self._last_tick_read.get(code) or {})
|
||
ob = dict(self._last_ob_read.get(code) or {})
|
||
need_tick = not (tick.get("label") or tick.get("vendor"))
|
||
need_ob = not (ob.get("label") or ob.get("vendor"))
|
||
if need_tick or need_ob:
|
||
# 0 → LIVE_FEED_FALLBACK 나이(기본 3초). None=나이무시는 프로브에 쓰지 않음.
|
||
try:
|
||
va = vendor_read_max_age_sec(0.0)
|
||
except Exception:
|
||
va = 3.0
|
||
if va is None:
|
||
va = 3.0
|
||
if need_tick:
|
||
try:
|
||
self.get_price(code)
|
||
except Exception:
|
||
pass
|
||
if need_ob:
|
||
try:
|
||
self.get_orderbook_snapshot(code, max_age_sec=float(va))
|
||
except Exception:
|
||
pass
|
||
with self._feed_read_lock:
|
||
tick = dict(self._last_tick_read.get(code) or {})
|
||
ob = dict(self._last_ob_read.get(code) or {})
|
||
return format_trigger_feed_trace(
|
||
tick, ob,
|
||
tick_primary=live_tick_primary(),
|
||
ob_primary=live_ob_primary(),
|
||
)
|
||
except Exception:
|
||
return ""
|
||
|
||
def _vendor_price(self, vendor: str, code: str, max_age_sec: Optional[float]):
|
||
v = self._normalize_feed_vendor(vendor, "")
|
||
try:
|
||
if v == "kiwoom" and self._kiwoom_ws:
|
||
return self._kiwoom_ws.get_price(code, max_age_sec=max_age_sec)
|
||
if v == "kis" and self.ws_cache:
|
||
return self.ws_cache.get_price(code, max_age_sec=max_age_sec)
|
||
if v == "ls":
|
||
ls_ws = self._get_ls_ws()
|
||
if ls_ws is not None:
|
||
return ls_ws.get_price(code, max_age_sec=max_age_sec)
|
||
except Exception:
|
||
return None
|
||
return None
|
||
|
||
def _vendor_orderbook_snap(self, vendor: str, code: str, max_age_sec: float):
|
||
v = self._normalize_feed_vendor(vendor, "")
|
||
try:
|
||
if v == "kiwoom" and self._kiwoom_ws and hasattr(self._kiwoom_ws, "get_orderbook_snapshot"):
|
||
return self._kiwoom_ws.get_orderbook_snapshot(code, max_age_sec=max_age_sec)
|
||
if v == "kis":
|
||
kis_src = self.kis_ws_ob
|
||
if kis_src is None or kis_src is self.ws_cache:
|
||
return None
|
||
if hasattr(kis_src, "get_orderbook_snapshot"):
|
||
return kis_src.get_orderbook_snapshot(code, max_age_sec=max_age_sec)
|
||
return None
|
||
if v == "ls":
|
||
ls_ws = self._get_ls_ws()
|
||
if ls_ws is not None and hasattr(ls_ws, "get_orderbook_snapshot"):
|
||
return ls_ws.get_orderbook_snapshot(code, max_age_sec=max_age_sec)
|
||
except Exception:
|
||
return None
|
||
return None
|
||
|
||
def inquire_kiwoom_rest_price(self, code: str) -> float:
|
||
"""4차 키움 ka10007 현재가. 한투 inquire_price(60초 캐시) 금지.
|
||
|
||
시세 실키(KIWOOM_WS_FORCE_REAL). 매매 KIS_MOCK 모의키를 쓰지 않음.
|
||
"""
|
||
code = str(code or "").strip()
|
||
if not code:
|
||
return 0.0
|
||
fn = fetch_kiwoom_cur_prc_ka10007
|
||
creds = _get_kiwoom_creds
|
||
if not callable(fn) or not callable(creds):
|
||
return 0.0
|
||
try:
|
||
key, secret, is_mock = creds(self.db)
|
||
except Exception:
|
||
return 0.0
|
||
if not key or not secret:
|
||
return 0.0
|
||
try:
|
||
return float(fn(code, key, secret, is_mock=bool(is_mock)) or 0.0)
|
||
except Exception:
|
||
return 0.0
|
||
|
||
def _clear_homes_if_unsubscribed(self, code: str) -> None:
|
||
code = (code or "").strip()
|
||
if not code:
|
||
return
|
||
with self._lock:
|
||
th = self._tick_home.get(code)
|
||
oh = self._ob_home.get(code)
|
||
if th and not self._code_subscribed_on(th, code):
|
||
# kis 호가만 남은 경우 등은 tick home 만 정리
|
||
still = False
|
||
if th == "kis" and self.ws_cache is not None:
|
||
still = self._code_subscribed_on("kis", code)
|
||
if not still:
|
||
with self._lock:
|
||
self._tick_home.pop(code, None)
|
||
self._tick_home_spill.discard(code)
|
||
if oh == "kis":
|
||
ob = self.kis_ws_ob
|
||
try:
|
||
if ob is None or ob is self.ws_cache:
|
||
gone = True
|
||
else:
|
||
with ob._sub_lock:
|
||
gone = code not in ob._subscribed
|
||
except Exception:
|
||
gone = True
|
||
if gone:
|
||
with self._lock:
|
||
self._ob_home.pop(code, None)
|
||
self._ob_home_spill.discard(code)
|
||
elif oh and not self._code_subscribed_on(oh, code):
|
||
with self._lock:
|
||
self._ob_home.pop(code, None)
|
||
self._ob_home_spill.discard(code)
|
||
|
||
def _start_reconcile_worker(self) -> None:
|
||
"""split 구독 reconcile 전담 — 갭 워커와 동일 패턴."""
|
||
if self._reconcile_worker_thread and self._reconcile_worker_thread.is_alive():
|
||
return
|
||
t = threading.Thread(
|
||
target=self._reconcile_worker_loop,
|
||
name="WS-ReconcileWorker",
|
||
daemon=True,
|
||
)
|
||
t.start()
|
||
self._reconcile_worker_thread = t
|
||
logger.info("✅ WS reconcile 워커 시작 (전략 루프 논블로킹)")
|
||
|
||
def _schedule_reconcile_split(self, *, include_ls_feed: bool = False) -> None:
|
||
"""owner 후보/보유 RAM 갱신 후 reconcile 요청 — debounce 로 4전략 합침."""
|
||
if not (self._split_feed_active and self.ws_cache and self._kiwoom_ws):
|
||
return
|
||
if include_ls_feed:
|
||
self._reconcile_ls_feed_pending = True
|
||
self._reconcile_event.set()
|
||
self._start_reconcile_worker()
|
||
|
||
def _reconcile_worker_loop(self) -> None:
|
||
"""debounce 후 1회 reconcile — 전략 쓰레드는 여기서 블록되지 않음."""
|
||
while True:
|
||
self._reconcile_event.wait()
|
||
debounce = float(get_env_float("WS_RECONCILE_DEBOUNCE_SEC", 0.25) or 0.0)
|
||
if debounce > 0:
|
||
time.sleep(debounce)
|
||
while self._reconcile_event.is_set():
|
||
self._reconcile_event.clear()
|
||
time.sleep(debounce)
|
||
else:
|
||
self._reconcile_event.clear()
|
||
|
||
if not (self._split_feed_active and self.ws_cache and self._kiwoom_ws):
|
||
self._reconcile_ls_feed_pending = False
|
||
continue
|
||
|
||
t0 = time.perf_counter()
|
||
with self._reconcile_lock:
|
||
try:
|
||
self._reconcile_split_subscriptions_locked()
|
||
except Exception as ex:
|
||
logger.warning("⚠️ [reconcile worker] split 예외: %s", ex)
|
||
split_ms = (time.perf_counter() - t0) * 1000.0
|
||
|
||
run_ls = self._reconcile_ls_feed_pending
|
||
self._reconcile_ls_feed_pending = False
|
||
if run_ls:
|
||
try:
|
||
self._reconcile_ls_feed_subscriptions()
|
||
except Exception as ex:
|
||
logger.debug("reconcile ls_feed 예외: %s", ex)
|
||
|
||
if split_ms >= 3000.0:
|
||
logger.warning(
|
||
"⚠️ [reconcile worker] split %.0fms (REG/호가/갭 enqueue)",
|
||
split_ms,
|
||
)
|
||
|
||
def _reconcile_split_subscriptions_locked(self) -> None:
|
||
"""_reconcile_lock 보유 가정 — 본문."""
|
||
# 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
|
||
# 영구구독은 LS WS로 이관 → KIS/키움 슬롯에서 제외
|
||
kis_want = hold_u_kw - perm
|
||
kis_want |= self._purge_kis_hold_grace()
|
||
kw_want = (cand_u_kw | hold_u_kw) - perm
|
||
tick_to_agg = set(cand_u_kw - hold_u_kw) - perm
|
||
# 갭 보정은 후보·보유만 (영구구독은 ls_ws_candles 탭 버튼)
|
||
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)
|
||
added_set = set(added_kw or [])
|
||
ob_primary = self._normalize_feed_vendor(
|
||
get_env_from_db("LIVE_OB_PROVIDER", "kiwoom") or "kiwoom",
|
||
"kiwoom",
|
||
)
|
||
for code in to_kw:
|
||
if code in added_set or self._code_subscribed_on("kiwoom", code):
|
||
self._mark_tick_home(code, "kiwoom", spilled=False)
|
||
# 호가 home은 LIVE_OB 체인(kis→kiwoom→ls). 틱=키움이어도 호가1차를 키움 강제 금지.
|
||
self._subscribe_ob_prefer_or_spill(code, ob_primary)
|
||
else:
|
||
# 한도/실패 → 즉시 kis→ls spill (sleep 없음)
|
||
self._subscribe_tick_prefer_or_spill(code, "kiwoom")
|
||
self._subscribe_ob_prefer_or_spill(code, ob_primary)
|
||
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)
|
||
# spill 로 붙은 코드도 갭 보강 (LS spill 은 LS 갭 경로 별도)
|
||
for code in to_kw:
|
||
if code in added_set or code in pure_ls_now:
|
||
continue
|
||
with self._lock:
|
||
home = self._tick_home.get(code)
|
||
if home == "ls":
|
||
continue
|
||
if home in ("kis", "kiwoom"):
|
||
if code in self._permanent_codes:
|
||
self._enqueue_gap_fill(code)
|
||
else:
|
||
gap_mode = self._candidate_gap_fill_mode(code, owner_cands)
|
||
self._enqueue_gap_fill(code, priority=True, mode=gap_mode)
|
||
|
||
to_kis = sorted(kis_want - kis_now)
|
||
hold_first = [
|
||
c for c in to_kis
|
||
if c in hold_u_kw or self._kis_hold_protected(c)
|
||
]
|
||
kis_rest = [c for c in to_kis if c not in set(hold_first)]
|
||
if hold_first:
|
||
self._ensure_kis_headroom_for_holdings(
|
||
len(hold_first), self._kis_keep_set(kis_want),
|
||
)
|
||
for code in hold_first + kis_rest:
|
||
home = self._subscribe_tick_prefer_or_spill(code, "kis")
|
||
if home:
|
||
self._enqueue_gap_fill(code)
|
||
# KIS 호가: OB 전용세션 또는 SAVE — prefer kis, 실패 시 키움→ls
|
||
if get_env_bool("WS_ORDERBOOK_SAVE_KIS", False) or (
|
||
(get_env_from_db("LIVE_OB_PROVIDER", "kiwoom") or "kiwoom").strip().lower()
|
||
== "kis"
|
||
):
|
||
self._subscribe_ob_prefer_or_spill(code, "kis")
|
||
|
||
for code in sorted(kis_now - kis_want):
|
||
# 보유·보유grace(180s)는 KIS 41에서 안 뺌
|
||
if self._kis_hold_protected(code):
|
||
continue
|
||
# 한투는 후보 grace 없음 — want 밖이면 즉시 해제
|
||
self.ws_cache.unsubscribe(code)
|
||
if self.kis_ws_ob is not None and self.kis_ws_ob is not self.ws_cache:
|
||
try:
|
||
self.kis_ws_ob.unsubscribe(code)
|
||
except Exception:
|
||
pass
|
||
self._clear_homes_if_unsubscribed(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)
|
||
# spill 로 LS 에만 남아 있던 경우 정리
|
||
ls = self._get_ls_ws()
|
||
if ls is not None:
|
||
try:
|
||
ls.unsubscribe(code, owner="spill")
|
||
except Exception:
|
||
pass
|
||
self._clear_homes_if_unsubscribed(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()
|
||
|
||
self._finalize_ls_subscriptions(kw_want, perm)
|
||
|
||
def _ws_grace_sec(self) -> int:
|
||
# 후보/보유 이탈 후에도 시세 피드 유지 (기본 180초, 3분)
|
||
# 전략 매수 검사는 Grace=0으로 즉시 중단되나, 분봉 시세는 끊김 없이 적재하여 재진입 시 갭보정 생략 & 0.001초 순간 포착 보장 (백테스트 100% 일치)
|
||
return max(0, get_env_int("WS_TICK_GRACE_SEC", 180))
|
||
|
||
def _ws_grace_headroom(self) -> int:
|
||
return max(0, get_env_int("WS_TICK_GRACE_HEADROOM", 5))
|
||
|
||
def _all_holdings(self) -> Set[str]:
|
||
with self._lock:
|
||
out: Set[str] = set()
|
||
for s in self._owner_holdings.values():
|
||
out |= set(s)
|
||
return {str(c).strip() for c in out if str(c).strip()}
|
||
|
||
def _purge_kis_hold_grace(self) -> Set[str]:
|
||
now = time.time()
|
||
with self._lock:
|
||
dead = [c for c, exp in self._kis_hold_grace_until.items() if exp <= now]
|
||
for c in dead:
|
||
self._kis_hold_grace_until.pop(c, None)
|
||
return set(self._kis_hold_grace_until.keys())
|
||
|
||
def _kis_hold_protected(self, code: str) -> bool:
|
||
code = (code or "").strip()
|
||
if not code:
|
||
return False
|
||
if code in self._all_holdings():
|
||
return True
|
||
self._purge_kis_hold_grace()
|
||
with self._lock:
|
||
return code in self._kis_hold_grace_until
|
||
|
||
def _arm_kis_hold_grace(self, code: str) -> bool:
|
||
"""보유에서 막 빠진 종목만 KIS 180초 유지. 후보는 대상 아님."""
|
||
grace = self._ws_grace_sec()
|
||
code = (code or "").strip()
|
||
if grace <= 0 or not code:
|
||
return False
|
||
if code in self._all_holdings():
|
||
return True
|
||
with self._lock:
|
||
if code in self._permanent_codes:
|
||
return False
|
||
if code in self._kis_hold_grace_until:
|
||
return True
|
||
self._kis_hold_grace_until[code] = time.time() + float(grace)
|
||
logger.info("📡 [KIS hold-grace] %s %ds (보유 이탈·41슬롯 유지)", code, int(grace))
|
||
return True
|
||
|
||
def _kis_keep_set(self, extra: Optional[Set[str]] = None) -> Set[str]:
|
||
keep = self._all_holdings() | self._purge_kis_hold_grace()
|
||
with self._lock:
|
||
keep |= set(self._permanent_codes)
|
||
if extra:
|
||
keep |= {str(c).strip() for c in extra if str(c).strip()}
|
||
return keep
|
||
|
||
def _ensure_kis_headroom_for_holdings(self, need: int, keep: Set[str]) -> None:
|
||
"""KIS 41 꽉 차면 후보(비보호)부터 해제 — 보유 pin 우선."""
|
||
if need <= 0 or self.ws_cache is None:
|
||
return
|
||
try:
|
||
limit = int(getattr(self.ws_cache, "MAX_SUBSCRIPTIONS", 41) or 41)
|
||
except Exception:
|
||
limit = 41
|
||
with self.ws_cache._sub_lock:
|
||
n_now = len(self.ws_cache._subscribed)
|
||
extra = [c for c in list(self.ws_cache._subscribed) if c not in keep]
|
||
free = limit - n_now
|
||
if free >= need:
|
||
return
|
||
drop_n = need - max(0, free)
|
||
dropped = 0
|
||
for code in extra:
|
||
if dropped >= drop_n:
|
||
break
|
||
try:
|
||
self.ws_cache.unsubscribe(code)
|
||
except Exception:
|
||
pass
|
||
dropped += 1
|
||
if dropped:
|
||
logger.info(
|
||
"📡 [KIS evict] 후보 %s 해제 (보유 pin headroom %d)",
|
||
code, drop_n,
|
||
)
|
||
|
||
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:
|
||
# _code_refs = {종목코드: {owner이름들}} 이므로
|
||
# keys() = 종목코드, values() = owner set (전략이름) — values 사용 시 버그
|
||
subscribed = set(perm) | set(self._code_refs.keys())
|
||
want = 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:
|
||
prefer = self._normalize_feed_vendor(
|
||
get_env_from_db("LIVE_TICK_PROVIDER", "kiwoom") or "kiwoom",
|
||
"kiwoom",
|
||
)
|
||
ob_primary = self._normalize_feed_vendor(
|
||
get_env_from_db("LIVE_OB_PROVIDER", "kiwoom") or "kiwoom",
|
||
"kiwoom",
|
||
)
|
||
# 기존: KIS+키움 동시 구독 유지(한도 내일 때). 실패분만 spill.
|
||
kis_ok = False
|
||
kw_ok = False
|
||
if self.ws_cache:
|
||
kis_ok = bool(self.ws_cache.subscribe(code))
|
||
if self._kiwoom_ws and not self._split_feed_active:
|
||
kw_ok = bool(self._kiwoom_ws.subscribe(code))
|
||
if kis_ok or kw_ok:
|
||
home = prefer if (
|
||
(prefer == "kis" and kis_ok) or (prefer == "kiwoom" and kw_ok)
|
||
) else ("kiwoom" if kw_ok else "kis")
|
||
self._mark_tick_home(code, home, spilled=False)
|
||
else:
|
||
self._subscribe_tick_prefer_or_spill(code, prefer)
|
||
# 호가: LIVE_OB 1차(kis=2키 OB) → kiwoom → ls. 틱=키움이어도 호가 home 키움 강제 금지.
|
||
if (
|
||
get_env_bool("WS_ORDERBOOK_SAVE_KIS", False)
|
||
or get_env_bool("WS_ORDERBOOK_SAVE_KIWOOM", True)
|
||
or ob_primary in ("kis", "kiwoom", "ls")
|
||
):
|
||
self._subscribe_ob_prefer_or_spill(code, ob_primary)
|
||
# 신규 구독 → 워커에게 갭보정 위임 (논블로킹) — 키움/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:
|
||
if self._kis_hold_protected(code):
|
||
# 보유·KIS 180s grace: 한투 틱 구독은 유지
|
||
if self._kiwoom_ws and not self._split_feed_active:
|
||
if not self._note_leave_for_grace(code):
|
||
try:
|
||
self._kiwoom_ws.unsubscribe(code)
|
||
except Exception:
|
||
pass
|
||
self._sync_tick_record_codes()
|
||
return
|
||
if self.ws_cache:
|
||
self.ws_cache.unsubscribe(code)
|
||
if self._kiwoom_ws and not self._split_feed_active:
|
||
self._kiwoom_ws.unsubscribe(code)
|
||
if self.kis_ws_ob is not None and self.kis_ws_ob is not self.ws_cache:
|
||
try:
|
||
self.kis_ws_ob.unsubscribe(code)
|
||
except Exception:
|
||
pass
|
||
ls = self._get_ls_ws()
|
||
if ls is not None:
|
||
try:
|
||
ls.unsubscribe(code, owner="spill")
|
||
except Exception:
|
||
pass
|
||
self._clear_homes_if_unsubscribed(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()))
|
||
hold_u = self._all_holdings()
|
||
added = sorted(new_set - cur)
|
||
added_hold = [c for c in added if c in hold_u]
|
||
added_rest = [c for c in added if c not in hold_u]
|
||
if added_hold:
|
||
self._ensure_kis_headroom_for_holdings(
|
||
len(added_hold), self._kis_keep_set(new_set),
|
||
)
|
||
for code in sorted(cur - new_set):
|
||
self.unsubscribe(code, owner)
|
||
for code in added_hold + added_rest:
|
||
self.subscribe(code, owner)
|
||
self._sync_tick_record_codes()
|
||
holdings = self._all_holdings()
|
||
with self._lock:
|
||
perm = set(self._permanent_codes)
|
||
kw_want = set(self._code_refs.keys()) | holdings
|
||
self._finalize_ls_subscriptions(kw_want, perm)
|
||
|
||
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: Any = _WS_PRICE_AGE_OMITTED,
|
||
) -> Optional[dict]:
|
||
"""현재가. 인자 생략 = 읽기 폴백 나이(LIVE_FEED_FALLBACK, 기본 3초)로 메인→2차→3차.
|
||
|
||
``max_age_sec=None`` 은 마지막 RAM(나이 무시, 진단용).
|
||
메인이 폴백 나이 넘으면 없는 것과 같고 체인을 계속한다. 4차 REST 는 여기 없음.
|
||
"""
|
||
from kis_trader.engine.feed_fallback import vendor_read_max_age_sec
|
||
|
||
if max_age_sec is None:
|
||
caller_age: Optional[float] = None
|
||
elif max_age_sec is _WS_PRICE_AGE_OMITTED:
|
||
try:
|
||
ws_age = float(get_env_float("WS_PRICE_MAX_AGE_SEC", 0.0) or 0.0)
|
||
except Exception:
|
||
ws_age = 0.0
|
||
caller_age = ws_age
|
||
else:
|
||
try:
|
||
caller_age = float(max_age_sec)
|
||
except (TypeError, ValueError):
|
||
caller_age = 0.0
|
||
resolved_age = vendor_read_max_age_sec(caller_age)
|
||
primary = self._normalize_feed_vendor(
|
||
get_env_from_db("LIVE_TICK_PROVIDER", "kiwoom") or "kiwoom",
|
||
"kiwoom",
|
||
)
|
||
with self._lock:
|
||
home = self._tick_home.get(code)
|
||
spilled = code in self._tick_home_spill
|
||
chain: List[str] = []
|
||
if self.is_ls_feed_code(code):
|
||
chain = ["ls"]
|
||
else:
|
||
if home and home not in chain:
|
||
chain.append(home)
|
||
for v in (primary, "kiwoom", "kis", "ls"):
|
||
if v and v not in chain:
|
||
chain.append(v)
|
||
for v in chain:
|
||
p = self._vendor_price(v, code, resolved_age)
|
||
if p:
|
||
if isinstance(p, dict):
|
||
p = dict(p)
|
||
p["_feed_vendor"] = v
|
||
try:
|
||
from kis_trader.engine.feed_fallback import extract_tick_trace_fields
|
||
meta = extract_tick_trace_fields(p if isinstance(p, dict) else None)
|
||
except Exception:
|
||
meta = None
|
||
self._remember_feed_read(
|
||
"tick", code, v, spilled=bool(spilled and v == home), meta=meta,
|
||
)
|
||
return p
|
||
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_price_last(self, code: str) -> Optional[dict]:
|
||
"""마지막 체결 RAM. 나이 무시 (진단·EOD). 매수·매도 판정은 get_price() 폴백 체인."""
|
||
return self.get_price(code, max_age_sec=None)
|
||
|
||
def get_orderbook_snapshot(self, code: str, max_age_sec: float = 3.0):
|
||
"""호가 스냅샷. 벤더별 폴백 나이 실패 시 2차→3차. 만료로 None 만 만들고 끝내지 않음.
|
||
|
||
ls_condition 코드는 LS UH1 만 (키움으로 메우지 않음).
|
||
"""
|
||
from kis_trader.engine.feed_fallback import vendor_read_max_age_sec
|
||
|
||
try:
|
||
caller = float(max_age_sec)
|
||
except (TypeError, ValueError):
|
||
caller = 0.0
|
||
vendor_age = vendor_read_max_age_sec(caller)
|
||
if vendor_age is None:
|
||
vendor_age = 0.0
|
||
primary = self._normalize_feed_vendor(
|
||
get_env_from_db("LIVE_OB_PROVIDER", "kiwoom") or "kiwoom",
|
||
"kiwoom",
|
||
)
|
||
with self._lock:
|
||
home = self._ob_home.get(code)
|
||
spilled = code in self._ob_home_spill
|
||
chain: List[str] = []
|
||
if self.is_ls_feed_code(code):
|
||
chain = ["ls"]
|
||
else:
|
||
if home:
|
||
chain.append(home)
|
||
for v in (primary, "kiwoom", "kis", "ls"):
|
||
if v and v not in chain:
|
||
chain.append(v)
|
||
for v in chain:
|
||
snap = self._vendor_orderbook_snap(v, code, float(vendor_age))
|
||
if snap is not None:
|
||
try:
|
||
setattr(snap, "_feed_vendor", v)
|
||
except Exception:
|
||
pass
|
||
try:
|
||
from kis_trader.engine.feed_fallback import extract_ob_trace_fields
|
||
meta = extract_ob_trace_fields(snap)
|
||
except Exception:
|
||
meta = None
|
||
self._remember_feed_read(
|
||
"ob", code, v, spilled=bool(spilled and v == home), meta=meta,
|
||
)
|
||
return snap
|
||
return None
|
||
|
||
def get_orderbook(self, code: str, max_age_sec: float = 3.0) -> Optional[dict]:
|
||
"""호가 dict — get_orderbook_snapshot 과 같은 폴백 체인. KIS 는 dict 폴백."""
|
||
from kis_trader.engine.feed_fallback import vendor_read_max_age_sec
|
||
|
||
try:
|
||
caller = float(max_age_sec)
|
||
except (TypeError, ValueError):
|
||
caller = 0.0
|
||
va = vendor_read_max_age_sec(caller)
|
||
if va is None:
|
||
va = 0.0
|
||
snap = self.get_orderbook_snapshot(code, max_age_sec=float(va))
|
||
if snap is not None and hasattr(snap, "to_kis_bid_dict"):
|
||
try:
|
||
return snap.to_kis_bid_dict()
|
||
except Exception:
|
||
pass
|
||
if isinstance(snap, dict):
|
||
return snap
|
||
kis_src = self.kis_ws_ob
|
||
if kis_src is not None and kis_src is not self.ws_cache and hasattr(kis_src, "get_orderbook"):
|
||
try:
|
||
ob = kis_src.get_orderbook(code, max_age_sec=float(va))
|
||
if ob:
|
||
self._remember_feed_read("ob", code, "kis")
|
||
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 _ls_confirmed_bars_ram(self, code: str, tf: int) -> list:
|
||
"""LS 확정봉 RAM만 (DB 재조회 없음). 분봉 3차 pick 용."""
|
||
ls = self._get_ls_ws()
|
||
if ls is None:
|
||
return []
|
||
c = str(code or "").strip()
|
||
tf_i = max(1, int(tf or 1))
|
||
try:
|
||
with ls._candle_lock:
|
||
buf = list(ls._confirmed.get((c, tf_i), []) or [])
|
||
except Exception:
|
||
return []
|
||
out = []
|
||
for b in buf:
|
||
row = dict(b)
|
||
row["source"] = "ls"
|
||
row["channel"] = "ws"
|
||
out.append(row)
|
||
return out
|
||
|
||
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 is_gap_ready(self, code: str) -> bool:
|
||
"""갭보정 완료 여부 — check_buy 진입 허용 판단용.
|
||
|
||
True → RAM에 필요한 TF 봉이 준비됐고 매수체크 진입 가능.
|
||
False → 갭보정 진행 중 또는 미시작 → check_buy 스킵해야 함.
|
||
"""
|
||
with self._gap_lock:
|
||
return code in self._gap_filled
|
||
|
||
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=강제OFF",
|
||
n_workers,
|
||
kw_status,
|
||
)
|
||
# 키움 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:
|
||
already_ok = tf in self._gap_tf_ok.get(code, set())
|
||
|
||
# 🛡️ [스마트 갭보정 사전 검열] RAM 봉 실측을 통해 무지성 REST 호출 차단
|
||
if self.candle_agg:
|
||
need = self._gap_fill_limit_for_tf(tf, code=code)
|
||
have = self.candle_agg.get_confirmed_count(code, tf)
|
||
# need-1 허용: 키움 REST는 항상 진행중 현재봉(1개)을 제외하므로 최대 need-1봉
|
||
if have >= max(1, need - 1):
|
||
if not already_ok:
|
||
self._mark_gap_tf_ok(code, tf)
|
||
logger.debug("🛡️ [스마트갭보정] %s %dM: RAM 실측 %d봉(>=%d) 충족 → REST 무지성 호출 차단", code, tf, have, need)
|
||
return True
|
||
# 이미 ok로 찍혔어도, 모멘텀 등 500봉이 필요한 종목이 500봉 미달 시 ok 해제 및 재보정 허용
|
||
# 단, 상위 TF(3M/15M/60M)가 이미 모두 ok 상태면 1M 스티커를 뜯어도 의미 없음 → 유지
|
||
elif already_ok and tf == 1 and self._code_needs_deep_1m(code) and have < max(1, self._momentum_min_candles() - 1):
|
||
with self._gap_lock:
|
||
ok_set_inner = self._gap_tf_ok.get(code, set())
|
||
higher_tfs = (
|
||
{t for t in self.candle_agg.timeframes if t > 1}
|
||
if self.candle_agg else set()
|
||
)
|
||
if not higher_tfs or not higher_tfs.issubset(ok_set_inner):
|
||
# 상위 TF 미완성 — 1M 재보정 필요
|
||
ok_set_inner.discard(1)
|
||
# 상위 TF 전부 ok면 1M 스티커 유지 (재시도 불필요)
|
||
already_ok = False
|
||
|
||
return already_ok
|
||
|
||
def _all_gap_tfs_ok(self, code: str) -> bool:
|
||
"""모든 TF 갭보정 완료 여부. _gap_tf_ok set 체크 + RAM 실측 보완."""
|
||
need = set(self.candle_agg.timeframes)
|
||
with self._gap_lock:
|
||
ok_set = set(self._gap_tf_ok.get(code, set()))
|
||
if need.issubset(ok_set):
|
||
return True
|
||
# RAM 실측으로 보완 (clear/경쟁 상태 대응): 현재봉 제외(-1) 허용
|
||
if self.candle_agg:
|
||
for tf in need - ok_set:
|
||
limit = self._gap_fill_limit_for_tf(tf, code=code)
|
||
if self.candle_agg.get_confirmed_count(code, tf) >= max(1, limit - 1):
|
||
self._mark_gap_tf_ok(code, tf)
|
||
ok_set.add(tf)
|
||
return need.issubset(ok_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 _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 REST 갭보정 금지 (페이지네이션). env ON 이어도 호출하지 않음.
|
||
if get_env_bool("WS_GAP_FILL_KIS_FALLBACK", False):
|
||
logger.info(
|
||
"⏭ [갭보정] WS_GAP_FILL_KIS_FALLBACK ON 무시 — 키움 ka10080만 사용 (%s)",
|
||
code,
|
||
)
|
||
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회·구멍 방지)
|
||
# 롤업 여부는 _gap_tf_ok set을 직접 확인 (스마트체크의 플래그 삭제 부작용 방지)
|
||
with self._gap_lock:
|
||
_1m_in_ok_set = 1 in self._gap_tf_ok.get(code, set())
|
||
if (
|
||
tf == 3
|
||
and get_env_bool("WS_GAP_ROLLUP_3M_FROM_1M", True)
|
||
and _1m_in_ok_set
|
||
):
|
||
self._maybe_rollup_3m_from_1m(code)
|
||
prev_tf = tf
|
||
continue
|
||
|
||
# 1M 성공 시 15M/60M 이상도 롤업으로 생성 — REST 추가 호출 금지
|
||
# (키움 REST는 1M만 1회, 나머지 TF는 전부 1M 롤업)
|
||
if (
|
||
tf > 3
|
||
and get_env_bool("WS_GAP_ROLLUP_HIGHER_TF_FROM_1M", True)
|
||
and _1m_in_ok_set
|
||
):
|
||
try:
|
||
n = int(self.candle_agg.rollup_tf_from_1m(code, tf) or 0)
|
||
have_tf = self.candle_agg.get_confirmed_count(code, tf)
|
||
if n > 0 or have_tf >= 1:
|
||
self._mark_gap_tf_ok(code, tf)
|
||
logger.info(
|
||
"🔧 [갭보정-롤업] %s 1M→%dM %d봉 보강 (확정=%d)",
|
||
code, tf, n, have_tf,
|
||
)
|
||
else:
|
||
# 롤업 0봉 — 키움은 15M/60M REST 미지원이므로 1M ok 기반 강제 완료
|
||
# REST로 회귀하면 빈응답 → abort 루프만 반복됨
|
||
self._mark_gap_tf_ok(code, tf)
|
||
logger.debug(
|
||
"🔧 [갭보정-롤업] %s %dM 롤업 0봉 → 1M 확보 기반 강제 완료 (REST 미지원)",
|
||
code, tf,
|
||
)
|
||
except Exception as e:
|
||
# 예외도 REST로 회귀하면 빈응답 abort — 강제 완료 처리
|
||
self._mark_gap_tf_ok(code, tf)
|
||
logger.debug("롤업 예외 %s %dM: %s — 강제 완료", code, tf, e)
|
||
prev_tf = tf
|
||
continue
|
||
if (
|
||
tf == 3
|
||
and get_env_bool("WS_GAP_ROLLUP_3M_FROM_1M", True)
|
||
and not _1m_in_ok_set
|
||
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 REST 갭보정은 사용하지 않음 (페이지·느림). 키움 빈응답은 빈 채로 둠.
|
||
|
||
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:
|
||
"""
|
||
영구구독 KR 코드 로드 (구독 본체는 LS WS. 한투 H0STCNT0·키움 0B 슬롯에 안 넣음).
|
||
우선순위: 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]
|