거래 빠르게 안티에서 병신만든거 커서로
feat: Implement backtest source management and enhance candle data handling Changes: - Introduced a new function `_apply_backtest_source_env_from_request` to manage the environment variables for candle, tick, and order book sources based on incoming requests. - Added a teardown function `_teardown_backtest_source_env` to ensure that environment variables do not persist between requests, enhancing the stability of the backtesting environment. - Refactored existing code to utilize the new source management functions, improving code readability and maintainability. - Added new utility functions in `bt_candle_source.py` for fetching and managing candle data, ensuring consistency with live trading data sources. Impact: - These changes improve the flexibility and reliability of the backtesting framework, allowing for better management of data sources and reducing the risk of cross-request contamination.
This commit is contained in:
@@ -160,6 +160,12 @@ class WSManager:
|
||||
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 표기용)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 시작/종료
|
||||
# ------------------------------------------------------------------
|
||||
@@ -215,7 +221,10 @@ class WSManager:
|
||||
logger.warning("KIS 호가 전용 WS(OB) 시작 실패 -> 메인 KIS WS로 폴백")
|
||||
self.kis_ws_ob = self.ws_cache
|
||||
else:
|
||||
logger.info("✅ KIS 호가 전용 WS(OB) 정상 시작 완료")
|
||||
# 시세 메인 / 호가 전용 역할 분리 (기존 SAVE DB값 변경 없음)
|
||||
self.ws_cache._ws_role = "tick"
|
||||
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", e)
|
||||
self.kis_ws_ob = self.ws_cache
|
||||
@@ -262,6 +271,7 @@ class WSManager:
|
||||
self._load_permanent_codes()
|
||||
for code in sorted(self._permanent_codes):
|
||||
self.ws_cache.subscribe(code)
|
||||
self._mark_tick_home(code, "kis", spilled=False)
|
||||
self._enqueue_gap_fill(code)
|
||||
logger.info("📡 [영구구독] %s", code)
|
||||
with self._lock:
|
||||
@@ -426,6 +436,19 @@ class WSManager:
|
||||
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/키움 슬롯 절약, LS는 RAM 전용(DB 미적재).
|
||||
split_feed 활성 시에만 호출됨. LS WS 없으면 무음 처리."""
|
||||
if not perm:
|
||||
return
|
||||
ls_ws = self._get_ls_ws()
|
||||
if ls_ws is None:
|
||||
return
|
||||
try:
|
||||
ls_ws.sync_owner_codes("_permanent", perm)
|
||||
except Exception as e:
|
||||
logger.debug("LS permanent sync 실패: %s", 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
|
||||
@@ -497,6 +520,323 @@ class WSManager:
|
||||
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"):
|
||||
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는 kis_ws_ob(역할 orderbook) 또는 SAVE 시 메인."""
|
||||
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 or self.ws_cache
|
||||
if ob is None:
|
||||
return False
|
||||
if spill and not getattr(ob, "is_active", False):
|
||||
return False
|
||||
# 별도 OB 세션이면 그쪽만; 아니면 메인 subscribe(SAVE 시 ASP0)
|
||||
if ob is not self.ws_cache:
|
||||
with ob._sub_lock:
|
||||
if code in ob._subscribed:
|
||||
return True
|
||||
return bool(ob.subscribe(code))
|
||||
if not get_env_bool("WS_ORDERBOOK_SAVE_KIS", False):
|
||||
# 메인에 호가 TR 안 붙는 설정 → kis 호가 항 스킵
|
||||
return False
|
||||
return self._try_subscribe_vendor_tick("kis", code, spill=spill)
|
||||
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:
|
||||
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 or self.ws_cache
|
||||
if ob is not None:
|
||||
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 get_tick_feed_label(self, code: str) -> str:
|
||||
"""매수/매도 MM 표기용 — 예: kiwoom / kis / ls(spill)."""
|
||||
code = (code or "").strip()
|
||||
with self._lock:
|
||||
home = self._tick_home.get(code)
|
||||
spilled = code in self._tick_home_spill
|
||||
if home:
|
||||
return f"{home}(spill)" if spilled else home
|
||||
# home 미확정: LIVE 기본
|
||||
return self._normalize_feed_vendor(
|
||||
get_env_from_db("LIVE_TICK_PROVIDER", "kiwoom") or "kiwoom",
|
||||
"kiwoom",
|
||||
)
|
||||
|
||||
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 or self.ws_cache
|
||||
try:
|
||||
if ob is None:
|
||||
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)
|
||||
elif oh and not self._code_subscribed_on(oh, code):
|
||||
with self._lock:
|
||||
self._ob_home.pop(code, None)
|
||||
|
||||
def _reconcile_split_subscriptions(self) -> None:
|
||||
"""KIS/키움 구독 집합을 후보·보유·영구 기준으로 재동기화."""
|
||||
if not (self._split_feed_active and self.ws_cache and self._kiwoom_ws):
|
||||
@@ -518,10 +858,13 @@ class WSManager:
|
||||
# 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
|
||||
# 영구구독은 LS WS로 이관 → KIS/키움 슬롯에서 제외
|
||||
# (LS_WS_TICK_SAVE=false 로 RAM 전용, DB 미적재)
|
||||
kis_want = hold_u_kw
|
||||
kw_want = cand_u_kw | hold_u_kw
|
||||
tick_to_agg = set(cand_u_kw - hold_u_kw)
|
||||
self._gap_refill_codes = set(kis_want) | set(kw_want)
|
||||
# 갭 보정은 영구구독도 포함 유지 (REST 봉차트 보강)
|
||||
self._gap_refill_codes = set(kis_want) | set(kw_want) | perm
|
||||
# 재진입 시 grace 재사용 가능하도록 소진 플래그 해제
|
||||
active_want = cand_u | hold_u | perm
|
||||
for code in active_want:
|
||||
@@ -559,6 +902,19 @@ class WSManager:
|
||||
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)
|
||||
@@ -574,14 +930,41 @@ class WSManager:
|
||||
# 전 후보 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)
|
||||
|
||||
for code in sorted(kis_want - kis_now):
|
||||
self.ws_cache.subscribe(code)
|
||||
self._enqueue_gap_fill(code)
|
||||
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):
|
||||
# KIS 는 grace 미적용 (영구+보유만) — 즉시 해제
|
||||
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)
|
||||
|
||||
@@ -592,6 +975,14 @@ class WSManager:
|
||||
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:
|
||||
@@ -603,6 +994,9 @@ class WSManager:
|
||||
|
||||
self._sync_tick_record_codes()
|
||||
|
||||
# 영구구독 코드를 LS WS에 sync (KIS/키움 슬롯 절약 — LS는 RAM 전용)
|
||||
self._sync_permanent_to_ls(perm)
|
||||
|
||||
def _ws_grace_sec(self) -> int:
|
||||
# 후보/보유 이탈 후에도 시세 피드 유지 (기본 180초, 3분)
|
||||
# 전략 매수 검사는 Grace=0으로 즉시 중단되나, 분봉 시세는 끊김 없이 적재하여 재진입 시 갭보정 생략 & 0.001초 순간 포착 보장 (백테스트 100% 일치)
|
||||
@@ -724,10 +1118,10 @@ class WSManager:
|
||||
# 실 체결(손절/익절) 정합을 위해 보유분 틱은 반드시 수집한다.
|
||||
want = cand_u | perm | hold_u | (subscribed - cand_u - hold_u)
|
||||
else:
|
||||
subscribed = set(perm)
|
||||
for refs in self._code_refs.values():
|
||||
subscribed |= set(refs)
|
||||
want = subscribed if scope in ("subscribed", "all", "full") else subscribed
|
||||
# _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)
|
||||
@@ -751,10 +1145,26 @@ class WSManager:
|
||||
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:
|
||||
self.ws_cache.subscribe(code)
|
||||
kis_ok = bool(self.ws_cache.subscribe(code))
|
||||
if self._kiwoom_ws and not self._split_feed_active:
|
||||
self._kiwoom_ws.subscribe(code)
|
||||
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()
|
||||
@@ -777,6 +1187,18 @@ class WSManager:
|
||||
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)
|
||||
@@ -815,7 +1237,7 @@ class WSManager:
|
||||
# 조회 헬퍼 (전략이 쓰는 API)
|
||||
# ------------------------------------------------------------------
|
||||
def get_price(self, code: str, max_age_sec: float = 5.0) -> Optional[dict]:
|
||||
# ls_condition 전략 코드 → LS WS 우선
|
||||
# ls_condition 전략 코드 → LS WS 우선 (LS feed 코드는 여기서만 조회)
|
||||
if self.is_ls_feed_code(code):
|
||||
ls_ws = self._get_ls_ws()
|
||||
if ls_ws is not None:
|
||||
@@ -827,6 +1249,27 @@ class WSManager:
|
||||
pass
|
||||
else:
|
||||
logger.debug("LS 피드 코드이나 LS WS 없음: %s", code)
|
||||
# spill home 우선 (있으면 그 벤더 RAM 먼저 — sleep 없음)
|
||||
with self._lock:
|
||||
home = self._tick_home.get(code)
|
||||
if home:
|
||||
try:
|
||||
if home == "kiwoom" and self._kiwoom_ws:
|
||||
p = self._kiwoom_ws.get_price(code, max_age_sec=max_age_sec)
|
||||
if p:
|
||||
return p
|
||||
elif home == "kis" and self.ws_cache:
|
||||
p = self.ws_cache.get_price(code, max_age_sec=max_age_sec)
|
||||
if p:
|
||||
return p
|
||||
elif home == "ls":
|
||||
ls_ws = self._get_ls_ws()
|
||||
if ls_ws is not None:
|
||||
p = ls_ws.get_price(code, max_age_sec=max_age_sec)
|
||||
if p:
|
||||
return p
|
||||
except Exception:
|
||||
pass
|
||||
from ..utils.env import get_env_from_db
|
||||
live_tick_provider = (get_env_from_db("LIVE_TICK_PROVIDER", "kiwoom") or "kiwoom").strip().lower()
|
||||
|
||||
@@ -838,13 +1281,22 @@ class WSManager:
|
||||
if p: return p
|
||||
except Exception:
|
||||
pass
|
||||
# 2. KIS 폴백
|
||||
# 2. KIS WS 폴백
|
||||
if self.ws_cache:
|
||||
try:
|
||||
p = self.ws_cache.get_price(code, max_age_sec=max_age_sec)
|
||||
if p: return p
|
||||
except Exception:
|
||||
pass
|
||||
# 3. LS WS 폴백 (RAM 캐시, DB 적재 없음 — 영구구독 코드 포함)
|
||||
if not 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:
|
||||
# 1. KIS 우선
|
||||
if self.ws_cache:
|
||||
@@ -860,6 +1312,15 @@ class WSManager:
|
||||
if p: return p
|
||||
except Exception:
|
||||
pass
|
||||
# 3. LS WS 폴백
|
||||
if not 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
|
||||
|
||||
return None
|
||||
|
||||
@@ -910,6 +1371,30 @@ class WSManager:
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
# spill ob_home 우선
|
||||
with self._lock:
|
||||
home = self._ob_home.get(code)
|
||||
if home:
|
||||
try:
|
||||
if home == "kiwoom" and self._kiwoom_ws and hasattr(self._kiwoom_ws, "get_orderbook_snapshot"):
|
||||
snap = self._kiwoom_ws.get_orderbook_snapshot(code, max_age_sec=max_age_sec)
|
||||
if snap is not None:
|
||||
return snap
|
||||
elif home == "kis":
|
||||
kis_src = self.kis_ws_ob or self.ws_cache
|
||||
if kis_src and hasattr(kis_src, "get_orderbook_snapshot"):
|
||||
snap = kis_src.get_orderbook_snapshot(code, max_age_sec=max_age_sec)
|
||||
if snap is not None:
|
||||
return snap
|
||||
elif home == "ls":
|
||||
ls_ws = self._get_ls_ws()
|
||||
if ls_ws is not None and hasattr(ls_ws, "get_orderbook_snapshot"):
|
||||
snap = ls_ws.get_orderbook_snapshot(code, max_age_sec=max_age_sec)
|
||||
if snap is not None:
|
||||
return snap
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
from ..utils.env import get_env_from_db
|
||||
live_ob_provider = (get_env_from_db("LIVE_OB_PROVIDER", "kiwoom") or "kiwoom").strip().lower()
|
||||
@@ -931,6 +1416,15 @@ class WSManager:
|
||||
if snap is not None: return snap
|
||||
except Exception:
|
||||
pass
|
||||
# 3. LS RAM 폴백
|
||||
ls_ws = self._get_ls_ws()
|
||||
if ls_ws is not None and hasattr(ls_ws, "get_orderbook_snapshot"):
|
||||
try:
|
||||
snap = ls_ws.get_orderbook_snapshot(code, max_age_sec=max_age_sec)
|
||||
if snap is not None:
|
||||
return snap
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
# 1. KIS 우선
|
||||
if kis_src and hasattr(kis_src, "get_orderbook_snapshot"):
|
||||
@@ -946,6 +1440,14 @@ class WSManager:
|
||||
if snap is not None: return snap
|
||||
except Exception:
|
||||
pass
|
||||
ls_ws = self._get_ls_ws()
|
||||
if ls_ws is not None and hasattr(ls_ws, "get_orderbook_snapshot"):
|
||||
try:
|
||||
snap = ls_ws.get_orderbook_snapshot(code, max_age_sec=max_age_sec)
|
||||
if snap is not None:
|
||||
return snap
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
@@ -963,6 +1465,31 @@ class WSManager:
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
with self._lock:
|
||||
home = self._ob_home.get(code)
|
||||
if home:
|
||||
try:
|
||||
if home == "kiwoom" and self._kiwoom_ws and hasattr(self._kiwoom_ws, "get_orderbook"):
|
||||
ob = self._kiwoom_ws.get_orderbook(code, max_age_sec=max_age_sec)
|
||||
if ob:
|
||||
return ob
|
||||
elif home == "kis":
|
||||
kis_src = self.kis_ws_ob or self.ws_cache
|
||||
if kis_src and hasattr(kis_src, "get_orderbook"):
|
||||
ob = kis_src.get_orderbook(code, max_age_sec=max_age_sec)
|
||||
if ob:
|
||||
return ob
|
||||
elif home == "ls":
|
||||
ls_ws = self._get_ls_ws()
|
||||
if ls_ws is not None:
|
||||
snap = ls_ws.get_orderbook_snapshot(code, max_age_sec=max_age_sec)
|
||||
if snap is not None and hasattr(snap, "to_kis_bid_dict"):
|
||||
return snap.to_kis_bid_dict()
|
||||
if isinstance(snap, dict):
|
||||
return snap
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
from ..utils.env import get_env_from_db
|
||||
live_ob_provider = (get_env_from_db("LIVE_OB_PROVIDER", "kiwoom") or "kiwoom").strip().lower()
|
||||
@@ -977,13 +1504,24 @@ class WSManager:
|
||||
if ob: return ob
|
||||
except Exception:
|
||||
pass
|
||||
# 2. KIS 폴백
|
||||
# 2. KIS WS 폴백
|
||||
if kis_src and hasattr(kis_src, "get_orderbook"):
|
||||
try:
|
||||
ob = kis_src.get_orderbook(code, max_age_sec=max_age_sec)
|
||||
if ob: return ob
|
||||
except Exception:
|
||||
pass
|
||||
# 3. LS WS 폴백 (RAM 캐시, DB 적재 없음)
|
||||
ls_ws = self._get_ls_ws()
|
||||
if ls_ws is not None:
|
||||
try:
|
||||
snap = ls_ws.get_orderbook_snapshot(code, max_age_sec=max_age_sec)
|
||||
if snap is not None and hasattr(snap, "to_kis_bid_dict"):
|
||||
return snap.to_kis_bid_dict()
|
||||
if isinstance(snap, dict):
|
||||
return snap
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
# 1. KIS 우선
|
||||
if kis_src and hasattr(kis_src, "get_orderbook"):
|
||||
@@ -999,6 +1537,17 @@ class WSManager:
|
||||
if ob: return ob
|
||||
except Exception:
|
||||
pass
|
||||
# 3. LS WS 폴백
|
||||
ls_ws = self._get_ls_ws()
|
||||
if ls_ws is not None:
|
||||
try:
|
||||
snap = ls_ws.get_orderbook_snapshot(code, max_age_sec=max_age_sec)
|
||||
if snap is not None and hasattr(snap, "to_kis_bid_dict"):
|
||||
return snap.to_kis_bid_dict()
|
||||
if isinstance(snap, dict):
|
||||
return snap
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
@@ -1052,6 +1601,15 @@ class WSManager:
|
||||
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.
|
||||
|
||||
@@ -1803,29 +2361,40 @@ class WSManager:
|
||||
if self.candle_agg:
|
||||
need = self._gap_fill_limit_for_tf(tf, code=code)
|
||||
have = self.candle_agg.get_confirmed_count(code, tf)
|
||||
if have >= need:
|
||||
# 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 해제 및 재보정 허용
|
||||
elif already_ok and tf == 1 and self._code_needs_deep_1m(code) and have < self._momentum_min_candles():
|
||||
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:
|
||||
self._gap_tf_ok.get(code, set()).discard(1)
|
||||
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 _all_gap_tfs_ok(self, code: str) -> bool:
|
||||
need = set(self.candle_agg.timeframes)
|
||||
with self._gap_lock:
|
||||
have = self._gap_tf_ok.get(code, set())
|
||||
return need.issubset(have)
|
||||
|
||||
def _fill_gap_for_code(
|
||||
self,
|
||||
code: str,
|
||||
@@ -1869,18 +2438,41 @@ class WSManager:
|
||||
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 self._gap_tf_already_ok(code, 1)
|
||||
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)
|
||||
if n > 0 or self.candle_agg.get_confirmed_count(code, tf) >= 1:
|
||||
self._mark_gap_tf_ok(code, tf)
|
||||
logger.info(
|
||||
"🔧 [갭보정-롤업] %s 1M→%dM %d봉 보강 (확정=%d)",
|
||||
code, tf, n, self.candle_agg.get_confirmed_count(code, tf),
|
||||
)
|
||||
except Exception as e:
|
||||
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 self._gap_tf_already_ok(code, 1)
|
||||
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 없으면 스킵)
|
||||
|
||||
Reference in New Issue
Block a user