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:
Your Name
2026-08-27 15:21:21 +09:00
parent ba2f9f0953
commit 5e44b86f8b
24 changed files with 1923 additions and 203 deletions

View File

@@ -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안 봉 내 돌파 등)."""