fix(시세): LS spill-only·토큰 파일캐시·호가 snap_time 3초컷
- _feed_fallback 미러 OFF, LS cap/grace/hold RAM을 KIS·키움 spill과 정합 - LS 접근토큰 .ls_token_cache_*.json (재시작 재사용, revoke 루프 없음) - 호가 RAM을 틱과 동일 LIVE_FEED_FALLBACK(snap_time)로 컷, 필터 max_age=0은 유지 - 익절 지정가 로그에 실제 호가 벤더(kis/kiwoom/ls 1·2·3차) 표기 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -287,21 +287,40 @@ class WSManager:
|
||||
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
|
||||
# Orderbook Snapshot Recorder 부착 (호가용, kis_ws_ob 기준)
|
||||
# attach 실패해도 recorder 자체는 유지 (키움 main 쪽 부착용)
|
||||
if (
|
||||
self.trigger_snapshot_recorder is not None
|
||||
and get_env_bool("WS_ORDERBOOK_SAVE_KIS", False)
|
||||
and self.kis_ws_ob is not None
|
||||
):
|
||||
try:
|
||||
self.kis_ws_ob.attach_trigger_snapshot_recorder(self.trigger_snapshot_recorder)
|
||||
logger.info("✅ KIS 호가 WS(OB)에 TriggerSnapshotRecorder 부착 완료 (KIS 호가 → kis_ws_orderbook 적재)")
|
||||
except Exception as att_ex:
|
||||
logger.warning("KIS OB TriggerSnapshotRecorder 부착 실패: %s", att_ex)
|
||||
elif get_env_bool("WS_ORDERBOOK_SAVE_KIS", False) and self.kis_ws_ob is None:
|
||||
logger.warning(
|
||||
"WS_ORDERBOOK_SAVE_KIS=ON 이나 2키 OB 세션 없음 → 적재 생략"
|
||||
)
|
||||
# 메인 틱 세션: 체결(H0STCNT0)마다 2키 OB RAM 틱동기 DB (키움 0B↔0D 와 동일)
|
||||
if (
|
||||
self.trigger_snapshot_recorder is not None
|
||||
and self.ws_cache is not None
|
||||
and get_env_bool("WS_ORDERBOOK_SAVE_KIS", False)
|
||||
):
|
||||
try:
|
||||
self.ws_cache.attach_trigger_snapshot_recorder(self.trigger_snapshot_recorder)
|
||||
if self.kis_ws_ob is not None and self.kis_ws_ob is not self.ws_cache:
|
||||
self.ws_cache.attach_orderbook_peer(self.kis_ws_ob)
|
||||
logger.info(
|
||||
"✅ KIS 메인 tick→OB peer 틱동기 부착 (H0STCNT0→H0STASP0 RAM→ws_orderbook)"
|
||||
)
|
||||
except Exception as peer_ex:
|
||||
logger.warning("KIS tick↔OB 틱동기 부착 실패: %s", peer_ex)
|
||||
|
||||
ok = self.ws_cache.start()
|
||||
if not ok:
|
||||
@@ -533,11 +552,11 @@ class WSManager:
|
||||
logger.debug("LS permanent sync 실패: %s", e)
|
||||
|
||||
def _sync_feed_fallback_to_ls(self, codes: "Set[str]") -> None:
|
||||
"""메인 틱/호가 유니버스를 LS 3차 RAM(시세+UH1)에 붙임. 갭 REST 아님.
|
||||
"""(레거시) 메인 유니버스 전체 미러 — 기본 OFF. spill/permanent 경로만 사용.
|
||||
|
||||
owner=_feed_fallback. 영구는 _permanent 와 합집합. 마스터 OFF 여도 폴백 구독은 독립.
|
||||
owner=_feed_fallback. ``LS_FEED_FALLBACK_SUBSCRIBE=false`` 이면 항상 빈 집합 sync.
|
||||
"""
|
||||
if not get_env_bool("LS_FEED_FALLBACK_SUBSCRIBE", True):
|
||||
if not get_env_bool("LS_FEED_FALLBACK_SUBSCRIBE", False):
|
||||
codes = set()
|
||||
ls_ws = self._get_ls_ws()
|
||||
if ls_ws is None:
|
||||
@@ -547,6 +566,112 @@ class WSManager:
|
||||
except Exception as e:
|
||||
logger.debug("LS feed-fallback sync 실패: %s", e)
|
||||
|
||||
def _bind_ls_ws_hooks(self) -> None:
|
||||
"""LS hold RAM 유지·headroom 등 WSManager 연동."""
|
||||
ls_ws = self._get_ls_ws()
|
||||
if ls_ws is None:
|
||||
return
|
||||
if getattr(ls_ws, "_ws_manager_bound", False):
|
||||
return
|
||||
ls_ws._hold_ram_extra_codes_fn = self._ls_hold_ram_extra_codes
|
||||
ls_ws._ws_manager_bound = True
|
||||
|
||||
def _ls_hold_ram_extra_codes(self) -> Set[str]:
|
||||
"""hold reopen 시 RAM 유지: 영구 + (옵션) 보유."""
|
||||
with self._lock:
|
||||
out = set(self._permanent_codes)
|
||||
if get_env_bool("LS_WS_HOLD_RAM_KEEP_HOLDINGS", True):
|
||||
out |= self._all_holdings()
|
||||
return {str(c).strip() for c in out if str(c).strip()}
|
||||
|
||||
def _ls_ephemeral_allowed_codes(
|
||||
self,
|
||||
kw_want: "Set[str]",
|
||||
perm: "Set[str]",
|
||||
) -> Set[str]:
|
||||
"""LS spill/_feed_fallback 이 유지할 수 있는 종목 (grace·ls_condition 포함)."""
|
||||
allowed = set(kw_want) | set(perm)
|
||||
grace = self._purge_and_get_grace_codes()
|
||||
if grace:
|
||||
allowed |= set(grace)
|
||||
with self._lock:
|
||||
allowed |= self._pure_ls_codes_locked()
|
||||
return {str(c).strip() for c in allowed if str(c).strip()}
|
||||
|
||||
def _prune_ls_ephemeral_subscriptions(
|
||||
self,
|
||||
kw_want: "Set[str]",
|
||||
perm: "Set[str]",
|
||||
) -> None:
|
||||
"""LS spill·_feed_fallback 정리 — KIS/키움 want/grace와 동일 수명."""
|
||||
self._bind_ls_ws_hooks()
|
||||
allowed = self._ls_ephemeral_allowed_codes(kw_want, perm)
|
||||
if get_env_bool("LS_FEED_FALLBACK_SUBSCRIBE", False):
|
||||
self._sync_feed_fallback_to_ls(self._ls_ram_universe_codes())
|
||||
else:
|
||||
self._sync_feed_fallback_to_ls(set())
|
||||
ls = self._get_ls_ws()
|
||||
if ls is None:
|
||||
return
|
||||
spill_drop: List[str] = []
|
||||
with ls._sub_lock:
|
||||
for code, ow in list(ls._sub_owners.items()):
|
||||
if "spill" not in ow:
|
||||
continue
|
||||
if code not in allowed:
|
||||
spill_drop.append(code)
|
||||
for code in spill_drop:
|
||||
try:
|
||||
ls.unsubscribe(code, owner="spill")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _finalize_ls_subscriptions(
|
||||
self,
|
||||
kw_want: "Set[str]",
|
||||
perm: "Set[str]",
|
||||
) -> None:
|
||||
"""reconcile/sync 끝: 영구 sync + spill-only 정리."""
|
||||
self._sync_permanent_to_ls(perm)
|
||||
self._prune_ls_ephemeral_subscriptions(kw_want, perm)
|
||||
|
||||
def _ensure_ls_headroom_for_new(self, need: int, allowed: Set[str]) -> None:
|
||||
"""LS 로컬 한도(기본 100) − headroom. grace 밖 spill 부터 해제."""
|
||||
if need <= 0:
|
||||
return
|
||||
ls = self._get_ls_ws()
|
||||
if ls is None:
|
||||
return
|
||||
try:
|
||||
limit = int(get_env_int("LS_WS_MAX_SUBSCRIPTIONS", 100) or 0)
|
||||
except Exception:
|
||||
limit = 100
|
||||
if limit <= 0:
|
||||
return
|
||||
headroom = self._ws_grace_headroom()
|
||||
soft_cap = max(1, limit - headroom)
|
||||
with ls._sub_lock:
|
||||
n_now = len(ls._subscribed) + len(getattr(ls, "_us_subscribed", set()))
|
||||
free = soft_cap - n_now
|
||||
if free >= need:
|
||||
return
|
||||
drop_n = need - max(0, free)
|
||||
with ls._sub_lock:
|
||||
candidates = [
|
||||
c for c in ls._subscribed
|
||||
if "spill" in (ls._sub_owners.get(c) or set())
|
||||
and c not in allowed
|
||||
]
|
||||
dropped = 0
|
||||
for code in candidates:
|
||||
if dropped >= drop_n:
|
||||
break
|
||||
try:
|
||||
ls.unsubscribe(code, owner="spill")
|
||||
except Exception:
|
||||
pass
|
||||
dropped += 1
|
||||
|
||||
def _ls_ram_universe_codes(self) -> Set[str]:
|
||||
"""LS RAM 미러 대상: 후보∪보유∪영구∪grace. MINIMAL 여부와 무관."""
|
||||
with self._lock:
|
||||
@@ -756,6 +881,20 @@ class WSManager:
|
||||
ls = self._get_ls_ws()
|
||||
if ls is None:
|
||||
return False
|
||||
self._bind_ls_ws_hooks()
|
||||
if spill:
|
||||
with self._lock:
|
||||
perm = set(self._permanent_codes)
|
||||
kw_want: Set[str] = set()
|
||||
if self._split_feed_active:
|
||||
for s in self._owner_candidates.values():
|
||||
kw_want |= s
|
||||
for s in self._owner_holdings.values():
|
||||
kw_want |= s
|
||||
else:
|
||||
kw_want = set(self._code_refs.keys())
|
||||
allowed = self._ls_ephemeral_allowed_codes(kw_want, perm)
|
||||
self._ensure_ls_headroom_for_new(1, allowed)
|
||||
# spill 경로: DB recorder 붙이지 않음 — RAM만 (기존 LS 적재 설정 변경 없음)
|
||||
owner = "spill" if spill else "default"
|
||||
return bool(ls.subscribe(code, owner=owner))
|
||||
@@ -876,7 +1015,9 @@ class WSManager:
|
||||
return None
|
||||
with self._lock:
|
||||
cur = self._ob_home.get(code)
|
||||
if cur:
|
||||
# 이미 prefer 벤더에 붙어 있으면 재전송 스킵.
|
||||
# cur≠prefer(예: 키움 틱 선마킹)면 LIVE_OB 1차로 업그레이드 시도.
|
||||
if cur and cur == prefer:
|
||||
if cur == "kis":
|
||||
ob = self.kis_ws_ob
|
||||
if ob is not None and ob is not self.ws_cache:
|
||||
@@ -889,7 +1030,9 @@ class WSManager:
|
||||
elif self._code_subscribed_on(cur, code):
|
||||
return cur
|
||||
|
||||
if self._vendor_object_present(prefer):
|
||||
if self._vendor_object_present(prefer) or (
|
||||
prefer == "kis" and self.kis_ws_ob is not None and self.kis_ws_ob is not self.ws_cache
|
||||
):
|
||||
if self._try_subscribe_vendor_ob(prefer, code, spill=False):
|
||||
self._mark_ob_home(code, prefer, spilled=False)
|
||||
return prefer
|
||||
@@ -999,7 +1142,11 @@ class WSManager:
|
||||
return self._label_from_read(rec, kind="ob", code=code)
|
||||
|
||||
def get_trigger_feed_trace(self, code: str) -> str:
|
||||
"""매수체크 로그용 — 1차설정·실제 벤더·현재가·틱타임·호가 한 줄."""
|
||||
"""매수체크 로그용 — 1차설정·실제 벤더·현재가·틱타임·호가 한 줄.
|
||||
|
||||
조기탈락이 get_price/get_orderbook 을 안 타면 _last_*_read 가 비어
|
||||
「틱실제=없음」이 과대계상됨 → 상세로그 ON 일 때 RAM 1회 프로브.
|
||||
"""
|
||||
code = (code or "").strip()
|
||||
if not code:
|
||||
return ""
|
||||
@@ -1009,12 +1156,36 @@ class WSManager:
|
||||
live_ob_primary,
|
||||
live_tick_primary,
|
||||
trigger_feed_detail_log_enabled,
|
||||
vendor_read_max_age_sec,
|
||||
)
|
||||
if not trigger_feed_detail_log_enabled():
|
||||
return ""
|
||||
with self._feed_read_lock:
|
||||
tick = dict(self._last_tick_read.get(code) or {})
|
||||
ob = dict(self._last_ob_read.get(code) or {})
|
||||
need_tick = not (tick.get("label") or tick.get("vendor"))
|
||||
need_ob = not (ob.get("label") or ob.get("vendor"))
|
||||
if need_tick or need_ob:
|
||||
# 0 → LIVE_FEED_FALLBACK 나이(기본 3초). None=나이무시는 프로브에 쓰지 않음.
|
||||
try:
|
||||
va = vendor_read_max_age_sec(0.0)
|
||||
except Exception:
|
||||
va = 3.0
|
||||
if va is None:
|
||||
va = 3.0
|
||||
if need_tick:
|
||||
try:
|
||||
self.get_price(code)
|
||||
except Exception:
|
||||
pass
|
||||
if need_ob:
|
||||
try:
|
||||
self.get_orderbook_snapshot(code, max_age_sec=float(va))
|
||||
except Exception:
|
||||
pass
|
||||
with self._feed_read_lock:
|
||||
tick = dict(self._last_tick_read.get(code) or {})
|
||||
ob = dict(self._last_ob_read.get(code) or {})
|
||||
return format_trigger_feed_trace(
|
||||
tick, ob,
|
||||
tick_primary=live_tick_primary(),
|
||||
@@ -1185,18 +1356,19 @@ class WSManager:
|
||||
if self._kiwoom_ws.subscribe(code):
|
||||
added_kw.append(code)
|
||||
added_set = set(added_kw or [])
|
||||
ob_primary = self._normalize_feed_vendor(
|
||||
get_env_from_db("LIVE_OB_PROVIDER", "kiwoom") or "kiwoom",
|
||||
"kiwoom",
|
||||
)
|
||||
for code in to_kw:
|
||||
if code in added_set or self._code_subscribed_on("kiwoom", code):
|
||||
self._mark_tick_home(code, "kiwoom", spilled=False)
|
||||
self._mark_ob_home(code, "kiwoom", spilled=False)
|
||||
# 호가 home은 LIVE_OB 체인(kis→kiwoom→ls). 틱=키움이어도 호가1차를 키움 강제 금지.
|
||||
self._subscribe_ob_prefer_or_spill(code, ob_primary)
|
||||
else:
|
||||
# 한도/실패 → 즉시 kis→ls spill (sleep 없음)
|
||||
self._subscribe_tick_prefer_or_spill(code, "kiwoom")
|
||||
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")
|
||||
self._subscribe_ob_prefer_or_spill(code, ob_primary)
|
||||
with self._lock:
|
||||
owner_cands = {
|
||||
str(owner): set(codes)
|
||||
@@ -1289,9 +1461,7 @@ class WSManager:
|
||||
|
||||
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())
|
||||
self._finalize_ls_subscriptions(kw_want, perm)
|
||||
|
||||
def _ws_grace_sec(self) -> int:
|
||||
# 후보/보유 이탈 후에도 시세 피드 유지 (기본 180초, 3분)
|
||||
@@ -1525,6 +1695,10 @@ class WSManager:
|
||||
get_env_from_db("LIVE_TICK_PROVIDER", "kiwoom") or "kiwoom",
|
||||
"kiwoom",
|
||||
)
|
||||
ob_primary = self._normalize_feed_vendor(
|
||||
get_env_from_db("LIVE_OB_PROVIDER", "kiwoom") or "kiwoom",
|
||||
"kiwoom",
|
||||
)
|
||||
# 기존: KIS+키움 동시 구독 유지(한도 내일 때). 실패분만 spill.
|
||||
kis_ok = False
|
||||
kw_ok = False
|
||||
@@ -1537,10 +1711,15 @@ class WSManager:
|
||||
(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)
|
||||
# 호가: LIVE_OB 1차(kis=2키 OB) → kiwoom → ls. 틱=키움이어도 호가 home 키움 강제 금지.
|
||||
if (
|
||||
get_env_bool("WS_ORDERBOOK_SAVE_KIS", False)
|
||||
or get_env_bool("WS_ORDERBOOK_SAVE_KIWOOM", True)
|
||||
or ob_primary in ("kis", "kiwoom", "ls")
|
||||
):
|
||||
self._subscribe_ob_prefer_or_spill(code, ob_primary)
|
||||
# 신규 구독 → 워커에게 갭보정 위임 (논블로킹) — 키움/KIS 경로
|
||||
self._enqueue_gap_fill(code)
|
||||
self._sync_tick_record_codes()
|
||||
@@ -1616,7 +1795,10 @@ class WSManager:
|
||||
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())
|
||||
with self._lock:
|
||||
perm = set(self._permanent_codes)
|
||||
kw_want = set(self._code_refs.keys()) | self._all_holdings()
|
||||
self._finalize_ls_subscriptions(kw_want, perm)
|
||||
|
||||
def get_recent_ticks(self, code: str, limit: int = 100) -> list:
|
||||
"""RAM 링버퍼 최근 체결 틱 (B안 봉 내 돌파 등)."""
|
||||
|
||||
Reference in New Issue
Block a user