ls증권 히스토리 구독 넣음

This commit is contained in:
Your Name
2026-07-30 18:05:07 +09:00
parent 61bec4bd1d
commit 67eab24603
1593 changed files with 135733 additions and 1232 deletions

View File

@@ -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()