Changes: - Added new API endpoints for continuing and confirming Optuna jobs, allowing for better management of ongoing studies. - Introduced detailed logging for tick feed tracking and order book processing, improving traceability of vendor performance during backtests. - Updated database schema to include new fields for managing Optuna study results, enhancing the ability to track study progress and outcomes. - Refactored existing functions to utilize the new logging and tracking features, ensuring consistency across the backtesting framework. Impact: - These enhancements improve the robustness and transparency of the Optuna backtesting process, facilitating better analysis and optimization of trading strategies.
2891 lines
126 KiB
Python
2891 lines
126 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()
|
||
# 갭보정 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)
|
||
# Orderbook Snapshot Recorder 부착 (호가용, kis_ws_ob 기준)
|
||
if (
|
||
self.trigger_snapshot_recorder
|
||
and get_env_bool("WS_ORDERBOOK_SAVE_KIS", False)
|
||
and self.kis_ws_ob is not None
|
||
):
|
||
self.kis_ws_ob.attach_trigger_snapshot_recorder(self.trigger_snapshot_recorder)
|
||
logger.info("✅ KIS 호가 WS(OB)에 TriggerSnapshotRecorder 부착 완료 (KIS 호가 → kis_ws_orderbook 적재)")
|
||
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 세션 없음 → 적재 생략"
|
||
)
|
||
except Exception as ts_ex:
|
||
logger.warning("TriggerSnapshotRecorder 기동 실패: %s", ts_ex)
|
||
self.trigger_snapshot_recorder = None
|
||
|
||
ok = self.ws_cache.start()
|
||
if not ok:
|
||
logger.warning("WS start() 실패 → 비활성")
|
||
self.ws_cache = None
|
||
self.candle_agg = None
|
||
self.tick_recorder = None
|
||
return False
|
||
|
||
# 갭보정 · 유통주식수 백그라운드 워커 (subscribe 논블로킹)
|
||
self._load_share_cache_from_db()
|
||
self._start_share_meta_worker()
|
||
self._start_gap_worker()
|
||
self._start_ls_gap_worker()
|
||
|
||
# 영구 구독 (KOSPI/KOSDAQ ETF 등) — 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
|
||
self._reconcile_split_subscriptions()
|
||
self._reconcile_ls_feed_subscriptions()
|
||
|
||
def _ls_feed_codes_locked(self) -> Set[str]:
|
||
"""호출자 _lock 보유 가정."""
|
||
out: Set[str] = set()
|
||
for s in self._ls_feed_owners.values():
|
||
out |= s
|
||
return out
|
||
|
||
def _non_ls_codes_locked(self) -> Set[str]:
|
||
"""ls_feed 가 아닌 전략의 후보∪보유. 호출자 _lock 보유."""
|
||
ls_owners = set(self._ls_feed_owners.keys())
|
||
out: Set[str] = set()
|
||
for own, s in self._owner_candidates.items():
|
||
if own not in ls_owners:
|
||
out |= s
|
||
for own, s in self._owner_holdings.items():
|
||
if own not in ls_owners:
|
||
out |= s
|
||
# 레거시 sync_targets 경로
|
||
for own, s in self._owner_codes.items():
|
||
if own not in ls_owners:
|
||
out |= s
|
||
return out
|
||
|
||
def _pure_ls_codes_locked(self) -> Set[str]:
|
||
"""예전: LS 전용 코드를 키움/갭에서 빼던 집합.
|
||
|
||
지금은 비움 — ls_condition 도 키움 갭·분봉을 쓰고, LS 는 틱만.
|
||
"""
|
||
return set()
|
||
|
||
def is_ls_feed_code(self, code: str) -> bool:
|
||
"""LS 틱·현재가 라우팅 대상 (갭/분봉은 키움)."""
|
||
with self._lock:
|
||
return code in self._ls_feed_codes_locked()
|
||
|
||
def _get_ls_ws(self):
|
||
try:
|
||
from ..ws.ls_ws import get_active_ls_ws
|
||
return get_active_ls_ws()
|
||
except Exception:
|
||
return None
|
||
|
||
def _reconcile_ls_feed_subscriptions(self) -> None:
|
||
"""ls_condition 코드를 LS US3 에만 sync — 갭(t8412)은 돌리지 않음."""
|
||
with self._lock:
|
||
owners = {
|
||
str(o): set(codes)
|
||
for o, codes in self._ls_feed_owners.items()
|
||
}
|
||
if not owners:
|
||
return
|
||
ls_ws = self._get_ls_ws()
|
||
if ls_ws is None:
|
||
if not self._ls_ws_missing_warned:
|
||
logger.warning(
|
||
"⚠️ ls_condition 틱용 LS WS 미기동 — "
|
||
"AFR/틱 수신 실패 가능 (main LS WS 확인)",
|
||
)
|
||
self._ls_ws_missing_warned = True
|
||
return
|
||
self._ls_ws_missing_warned = False
|
||
for owner, codes in owners.items():
|
||
try:
|
||
ls_ws.sync_owner_codes(owner, codes)
|
||
except Exception as e:
|
||
logger.warning("LS sync_owner_codes(%s) 실패: %s", owner, e)
|
||
|
||
def _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:
|
||
"""메인 틱/호가 유니버스를 LS 3차 RAM(시세+UH1)에 붙임. 갭 REST 아님.
|
||
|
||
owner=_feed_fallback. 영구는 _permanent 와 합집합. 마스터 OFF 여도 폴백 구독은 독립.
|
||
"""
|
||
if not get_env_bool("LS_FEED_FALLBACK_SUBSCRIBE", True):
|
||
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 _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
|
||
# 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)
|
||
if cur:
|
||
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):
|
||
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차설정·실제 벤더·현재가·틱타임·호가 한 줄."""
|
||
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,
|
||
)
|
||
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 {})
|
||
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 _reconcile_split_subscriptions(self) -> None:
|
||
"""MINIMAL 재동기화: 키움=후보∪보유, 한투=보유만, 영구KR=LS (한투/키움 슬롯 제외).
|
||
|
||
LIVE_TICK 을 읽지 않음. 메인 벤더와 구독 명단은 별개.
|
||
"""
|
||
if not (self._split_feed_active and self.ws_cache and self._kiwoom_ws):
|
||
return
|
||
# permanent_subscriptions 테이블 갱신 반영 (보유 해제 후에도 영구구독 틱 유지)
|
||
now = time.time()
|
||
if now - self._permanent_reload_ts >= 300.0:
|
||
self._load_permanent_codes()
|
||
self._permanent_reload_ts = now
|
||
with self._lock:
|
||
cand_u: Set[str] = set()
|
||
for s in self._owner_candidates.values():
|
||
cand_u |= s
|
||
hold_u: Set[str] = set()
|
||
for s in self._owner_holdings.values():
|
||
hold_u |= s
|
||
perm = set(self._permanent_codes)
|
||
pure_ls = self._pure_ls_codes_locked() - perm
|
||
# ls_condition 전용 종목은 키움/KIS 후보·갭에서 제외 (교차 폭주 방지)
|
||
cand_u_kw = cand_u - pure_ls
|
||
hold_u_kw = hold_u - pure_ls
|
||
# 영구구독은 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 [])
|
||
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)
|
||
self._mark_ob_home(code, "kiwoom", spilled=False)
|
||
else:
|
||
# 한도/실패 → 즉시 kis→ls spill (sleep 없음)
|
||
self._subscribe_tick_prefer_or_spill(code, "kiwoom")
|
||
if get_env_bool("WS_ORDERBOOK_SAVE_KIWOOM", True) or (
|
||
(get_env_from_db("LIVE_OB_PROVIDER", "kiwoom") or "kiwoom").strip().lower()
|
||
== "kiwoom"
|
||
):
|
||
self._subscribe_ob_prefer_or_spill(code, "kiwoom")
|
||
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()
|
||
|
||
# 영구구독 코드를 LS WS에 sync (KIS/키움 슬롯 절약 — LS는 RAM 전용)
|
||
self._sync_permanent_to_ls(perm)
|
||
self._sync_feed_fallback_to_ls(self._ls_ram_universe_codes())
|
||
|
||
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",
|
||
)
|
||
# 기존: 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)
|
||
if kw_ok:
|
||
self._mark_ob_home(code, "kiwoom", spilled=False)
|
||
else:
|
||
self._subscribe_tick_prefer_or_spill(code, prefer)
|
||
# 신규 구독 → 워커에게 갭보정 위임 (논블로킹) — 키움/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()
|
||
self._sync_feed_fallback_to_ls(self._ls_ram_universe_codes())
|
||
|
||
def get_recent_ticks(self, code: str, limit: int = 100) -> list:
|
||
"""RAM 링버퍼 최근 체결 틱 (B안 봉 내 돌파 등)."""
|
||
if self.tick_recorder:
|
||
try:
|
||
return self.tick_recorder.get_recent_ticks(code, limit=limit)
|
||
except Exception:
|
||
return []
|
||
return []
|
||
|
||
# ------------------------------------------------------------------
|
||
# 조회 헬퍼 (전략이 쓰는 API)
|
||
# ------------------------------------------------------------------
|
||
def get_price(
|
||
self, code: str, max_age_sec: 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]
|