ls증권 히스토리 구독 넣음
This commit is contained in:
@@ -115,6 +115,8 @@ class WSManager:
|
||||
self._lock = threading.Lock()
|
||||
# 갭보정 WS 재접속 시: split 모드면 KIS∪키움 관심 종목 전체
|
||||
self._gap_refill_codes: Set[str] = set()
|
||||
# BaseStrategy 틱매도 등 — 시세 캐시 갱신 리스너
|
||||
self._price_listeners: list = []
|
||||
|
||||
# ── 갭보정 비동기 파이프라인 ─────────────────────────────
|
||||
# (전략 쓰레드에서 subscribe() 시 동기 REST 호출하면 매수 체크가
|
||||
@@ -126,12 +128,19 @@ class WSManager:
|
||||
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)
|
||||
@@ -139,6 +148,17 @@ class WSManager:
|
||||
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
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 시작/종료
|
||||
# ------------------------------------------------------------------
|
||||
@@ -206,6 +226,7 @@ class WSManager:
|
||||
self._load_share_cache_from_db()
|
||||
self._start_share_meta_worker()
|
||||
self._start_gap_worker()
|
||||
self._start_ls_gap_worker()
|
||||
|
||||
# 영구 구독 (KOSPI/KOSDAQ ETF 등)
|
||||
self._load_permanent_codes()
|
||||
@@ -218,11 +239,12 @@ class WSManager:
|
||||
|
||||
# 연결 성공 후 자동 갭 보정 등록 (WS 재접속 시 전체 재갭보정)
|
||||
self.ws_cache.set_on_connected_callback(self._trigger_bulk_refill_async)
|
||||
self._reattach_all_price_listeners()
|
||||
|
||||
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", 4), 4)),
|
||||
max(1, min(get_env_int("WS_GAP_FILL_WORKERS", 2), 4)),
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
@@ -248,6 +270,7 @@ class WSManager:
|
||||
def set_kiwoom_ws(self, kiwoom_ws: Any) -> None:
|
||||
"""키움 WS 인스턴스 (기동 후 주입). ``activate_split_feed`` 전에 설정."""
|
||||
self._kiwoom_ws = kiwoom_ws
|
||||
self._reattach_all_price_listeners()
|
||||
|
||||
def activate_split_feed(self, active: bool) -> None:
|
||||
"""``WS_SUBSCRIBE_KIS_MINIMAL`` + 키움 준비 완료 후 True → 후보 구독을 키움으로."""
|
||||
@@ -260,23 +283,178 @@ class WSManager:
|
||||
owner: str,
|
||||
candidates: Iterable[str],
|
||||
holdings: Iterable[str],
|
||||
*,
|
||||
ls_feed: bool = False,
|
||||
) -> None:
|
||||
"""전략별 후보/보유를 분리 반영. ``WS_SUBSCRIBE_KIS_MINIMAL`` 아니면 레거시와 동일."""
|
||||
"""전략별 후보/보유를 분리 반영. ``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}
|
||||
with self._lock:
|
||||
if ls_feed:
|
||||
self._ls_feed_owners[owner] = set(cand | hold)
|
||||
else:
|
||||
self._ls_feed_owners.pop(owner, None)
|
||||
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 _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
|
||||
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)
|
||||
|
||||
def _reconcile_split_subscriptions(self) -> None:
|
||||
"""KIS/키움 구독 집합을 후보·보유·영구 기준으로 재동기화."""
|
||||
@@ -295,9 +473,13 @@ class WSManager:
|
||||
for s in self._owner_holdings.values():
|
||||
hold_u |= s
|
||||
perm = set(self._permanent_codes)
|
||||
kis_want = perm | hold_u
|
||||
kw_want = cand_u | hold_u | perm
|
||||
tick_to_agg = set(cand_u - hold_u)
|
||||
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
|
||||
kis_want = perm | hold_u_kw
|
||||
kw_want = cand_u_kw | hold_u_kw | perm
|
||||
tick_to_agg = set(cand_u_kw - hold_u_kw)
|
||||
self._gap_refill_codes = set(kis_want) | set(kw_want)
|
||||
# 재진입 시 grace 재사용 가능하도록 소진 플래그 해제
|
||||
active_want = cand_u | hold_u | perm
|
||||
@@ -341,7 +523,10 @@ class WSManager:
|
||||
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:
|
||||
@@ -525,7 +710,7 @@ class WSManager:
|
||||
|
||||
if first_ref and self.ws_cache:
|
||||
self.ws_cache.subscribe(code)
|
||||
# 신규 구독 → 워커에게 갭보정 위임 (논블로킹)
|
||||
# 신규 구독 → 워커에게 갭보정 위임 (논블로킹) — 키움/KIS 경로
|
||||
self._enqueue_gap_fill(code)
|
||||
self._sync_tick_record_codes()
|
||||
|
||||
@@ -578,19 +763,22 @@ class WSManager:
|
||||
return []
|
||||
return []
|
||||
|
||||
def get_current_candle(self, code: str, tf: int) -> Optional[dict]:
|
||||
"""진행 중 봉 (RAM, is_confirmed=0) — B안 거래량·양봉 판정용."""
|
||||
if self.candle_agg:
|
||||
try:
|
||||
return self.candle_agg.get_current_candle(code, tf)
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 조회 헬퍼 (전략이 쓰는 API)
|
||||
# ------------------------------------------------------------------
|
||||
def get_price(self, code: str, max_age_sec: float = 5.0) -> Optional[dict]:
|
||||
# ls_condition 전략 코드 → LS WS 우선
|
||||
if self.is_ls_feed_code(code):
|
||||
ls_ws = self._get_ls_ws()
|
||||
if ls_ws is not None:
|
||||
try:
|
||||
p = ls_ws.get_price(code, max_age_sec=max_age_sec)
|
||||
if p:
|
||||
return p
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
logger.debug("LS 피드 코드이나 LS WS 없음: %s", code)
|
||||
if self.ws_cache:
|
||||
try:
|
||||
p = self.ws_cache.get_price(code, max_age_sec=max_age_sec)
|
||||
@@ -605,6 +793,41 @@ class WSManager:
|
||||
return None
|
||||
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_orderbook_snapshot(self, code: str, max_age_sec: float = 3.0):
|
||||
"""키움 0D 호가 스냅샷 (분리 시세·키움 WS 활성 시)."""
|
||||
if self._kiwoom_ws and hasattr(self._kiwoom_ws, "get_orderbook_snapshot"):
|
||||
@@ -639,6 +862,7 @@ class WSManager:
|
||||
return None
|
||||
|
||||
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)
|
||||
@@ -650,21 +874,50 @@ class WSManager:
|
||||
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`` 여도 재큐 (RAM 삭제 후 재ENTER·봉부족 복구).
|
||||
force=True: 이미 ``_gap_filled`` 여도 재큐.
|
||||
"""
|
||||
self._maybe_arm_session_gap_refill()
|
||||
if codes is None:
|
||||
self._trigger_bulk_refill_async()
|
||||
else:
|
||||
for c in codes:
|
||||
self._enqueue_gap_fill(c, force=bool(force), priority=bool(force))
|
||||
return
|
||||
for c in codes:
|
||||
self._enqueue_gap_fill(c, force=bool(force), priority=bool(force))
|
||||
|
||||
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 봉 삭제와 짝 — 갭보정 완료 마커 해제.
|
||||
@@ -677,6 +930,80 @@ class WSManager:
|
||||
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 봉 정리 + 갭보정 재실행 가능하도록 상태 리셋."""
|
||||
@@ -694,7 +1021,7 @@ class WSManager:
|
||||
# ------------------------------------------------------------------
|
||||
def _start_gap_worker(self) -> None:
|
||||
"""갭보정 백그라운드 워커 N개 기동 — 우선큐(후보 1M)와 일반큐 병렬 소진."""
|
||||
want = max(1, min(get_env_int("WS_GAP_FILL_WORKERS", 4), 4))
|
||||
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
|
||||
@@ -758,6 +1085,11 @@ class WSManager:
|
||||
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
|
||||
@@ -942,18 +1274,32 @@ class WSManager:
|
||||
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)
|
||||
self._gap_filled.add(code)
|
||||
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:
|
||||
@@ -969,7 +1315,7 @@ class WSManager:
|
||||
else:
|
||||
kw_status = "❌"
|
||||
n_workers = max(
|
||||
1, min(get_env_int("WS_GAP_FILL_WORKERS", 4), 4),
|
||||
1, min(get_env_int("WS_GAP_FILL_WORKERS", 2), 4),
|
||||
)
|
||||
logger.info(
|
||||
"🔧 [갭보정-워커×%d] kiwoom=%s, KIS_fallback=%s",
|
||||
@@ -977,6 +1323,11 @@ class WSManager:
|
||||
kw_status,
|
||||
"ON" if get_env_bool("WS_GAP_FILL_KIS_FALLBACK", False) else "OFF",
|
||||
)
|
||||
# 키움 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:
|
||||
@@ -993,6 +1344,7 @@ class WSManager:
|
||||
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)
|
||||
@@ -1013,6 +1365,7 @@ class WSManager:
|
||||
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)
|
||||
@@ -1036,9 +1389,11 @@ class WSManager:
|
||||
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)
|
||||
@@ -1059,6 +1414,10 @@ class WSManager:
|
||||
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()
|
||||
@@ -1330,6 +1689,8 @@ class WSManager:
|
||||
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
|
||||
@@ -1408,7 +1769,10 @@ class WSManager:
|
||||
if tf == 1:
|
||||
self._maybe_rollup_3m_from_1m(code)
|
||||
else:
|
||||
logger.warning("⚠️ [갭보정] %s %dM → REST 빈 응답 (재시도 대상)", code, tf)
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user