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

@@ -562,6 +562,8 @@ def run_validation() -> bool:
"WS_CANDLE_FREEZE_ON_CONFIRM",
"LIVE_FEED_FALLBACK_MAX_AGE_SEC",
"LS_FEED_FALLBACK_SUBSCRIBE",
"LS_WS_HOLD_RAM_KEEP_HOLDINGS",
"LS_WS_MAX_SUBSCRIPTIONS",
"LS_GAP_FILL_CANDIDATES",
"LS_WS_TICK_SAVE",
"LS_WS_ALSO_HOGA",
@@ -597,6 +599,8 @@ def run_validation() -> bool:
"PERM_LS_FILL_BARS",
"LIVE_FEED_FALLBACK_MAX_AGE_SEC",
"LS_FEED_FALLBACK_SUBSCRIBE",
"LS_WS_HOLD_RAM_KEEP_HOLDINGS",
"LS_WS_MAX_SUBSCRIPTIONS",
"LS_WS_TICK_SAVE",
"LS_WS_ALSO_HOGA",
"LS_WS_ORDERBOOK_SAVE",
@@ -742,19 +746,43 @@ def run_validation() -> bool:
else:
_성공("LS 3차 폴백 구독 _sync_feed_fallback_to_ls")
st_src = inspect.getsource(WSManager.sync_targets)
if "_sync_feed_fallback_to_ls" not in st_src or "_ls_ram_universe_codes" not in st_src:
실패목록.append("sync_targets LS RAM 합집합 없음")
_실패("sync_targets 가 _sync_feed_fallback_to_ls(_ls_ram_universe_codes) 를 안 부름")
else:
_성공("sync_targets 끝에서 LS RAM 합집합 구독 (MINIMAL OFF 경로)")
rec_src = inspect.getsource(WSManager._reconcile_split_subscriptions)
if "_sync_feed_fallback_to_ls" not in rec_src or "_ls_ram_universe_codes" not in rec_src:
실패목록.append("split reconcile LS RAM 합집합 없음")
_실패("_reconcile_split_subscriptions 가 LS RAM 합집합을 안 부름")
if "_finalize_ls_subscriptions" not in rec_src:
실패목록.append("split reconcile LS finalize 없음")
_실패("_reconcile_split_subscriptions 가 _finalize_ls_subscriptions 를 안 부름")
else:
_성공("split reconcile 도 LS RAM 합집합")
_성공("split reconcile → _finalize_ls_subscriptions (spill/permanent)")
if not hasattr(WSManager, "_prune_ls_ephemeral_subscriptions"):
실패목록.append("_prune_ls_ephemeral_subscriptions 없음")
_실패("LS spill grace 정리 헬퍼 없음")
else:
prune_src = inspect.getsource(WSManager._prune_ls_ephemeral_subscriptions)
if 'owner="spill"' not in prune_src:
실패목록.append("LS spill prune 없음")
_실패("_prune_ls_ephemeral_subscriptions 가 spill unsubscribe 안 함")
else:
_성공("LS spill/_feed_fallback ephemeral prune")
st_src = inspect.getsource(WSManager.sync_targets)
if "_finalize_ls_subscriptions" not in st_src:
실패목록.append("sync_targets LS finalize 없음")
_실패("sync_targets 가 _finalize_ls_subscriptions 를 안 부름")
else:
_성공("sync_targets 끝에서 LS finalize (MINIMAL OFF 경로)")
fb_src = inspect.getsource(WSManager._sync_feed_fallback_to_ls)
if 'LS_FEED_FALLBACK_SUBSCRIBE", False' in fb_src or "LS_FEED_FALLBACK_SUBSCRIBE', False" in fb_src:
_성공("LS_FEED_FALLBACK_SUBSCRIBE 코드 기본값=false (미러 OFF)")
else:
실패목록.append("LS_FEED_FALLBACK 기본 false 아님")
_실패("_sync_feed_fallback_to_ls get_env_bool 기본값이 false 가 아님")
db_fb = get_env_bool("LS_FEED_FALLBACK_SUBSCRIBE", False)
if db_fb:
print(
f" ⚠️ DB LS_FEED_FALLBACK_SUBSCRIBE=ON — 레거시 미러 활성. "
f"spill-only 운영 시 웹/DB에서 OFF 권장 (현재={_db원문('LS_FEED_FALLBACK_SUBSCRIBE')})"
)
start_src = inspect.getsource(WSManager.start)
if "kis_ws_ob = self.ws_cache" in start_src:
@@ -879,14 +907,55 @@ def run_validation() -> bool:
if not ps.should_persist_ls("005930", {"005930", "069500"}):
실패목록.append("should_persist_ls True 기대")
_실패("영구구독 코드인데 LS 적재 가드 False")
_실패("영구구독 코드인데 LS 봉/VI 가드 False")
else:
_성공("should_persist_ls: 영구구독 코드만 True")
_성공("should_persist_ls: 영구구독 코드만 True (봉·VI)")
if ps.should_persist_ls("999999", {"005930"}):
실패목록.append("should_persist_ls False 기대")
_실패("비영구 코드인데 LS 적재 허용")
_실패("비영구 코드인데 LS 봉/VI 적재 허용")
else:
_성공("should_persist_ls: 후보 spill 코드 False")
_성공("should_persist_ls: 후보 spill 코드 False (봉·VI)")
# 체결틱 DB 게이트 = 구독 전체 (_ls_is_subscribed), 영구만이 아님
try:
from pathlib import Path as _Path
_main_py = (_Path(__file__).resolve().parents[1] / "kis_trader" / "main.py").read_text(
encoding="utf-8", errors="replace",
)
except Exception:
_main_py = ""
if "LS_WS_TICK_SAVE" in _main_py and "_ls_is_subscribed(code)" in _main_py:
_성공("LS 체결틱 DB: 구독 전체(_ls_is_subscribed)")
elif "LS_WS_TICK_SAVE" in _main_py and "and self._ls_should_persist(code)" in _main_py:
실패목록.append("LS 틱이 영구게이트")
_실패("LS_WS_TICK_SAVE 가 _ls_should_persist(영구) — 구독전체여야 함")
else:
_주의("LS 틱 SAVE 게이트 패턴 확인 필요")
from kis_trader.engine.feed_fallback import merge_ls_ticks_third_fallback
_base = {
"005930": {
"202608191200": [
{"tick_time": "20260819120000", "source": "kis", "price": 1, "_lag_sec": 0},
]
}
}
_ls = {
"005930": {
"202608191200": [
{"tick_time": "20260819120000", "source": "ls", "price": 9, "_lag_sec": 0},
{"tick_time": "20260819120001", "source": "ls", "price": 8, "_lag_sec": 0},
]
}
}
_add = merge_ls_ticks_third_fallback(_base, _ls, max_lag_sec=3.0)
_sec0 = [t for t in _base["005930"]["202608191200"] if str(t.get("tick_time")) == "20260819120000"]
_sec1 = [t for t in _base["005930"]["202608191200"] if str(t.get("tick_time")) == "20260819120001"]
if _add != 1 or any(str(t.get("source")) == "ls" for t in _sec0) or not _sec1:
실패목록.append(f"LS 틱3차 merge 이상 add={_add}")
_실패(f"같은초 LS 중복/빈초 미채움: add={_add} sec0={_sec0} sec1={_sec1}")
else:
_성공("틱 3차 LS merge: 같은초 스킵·빈초만 채움")
if not hasattr(db, "insert_ls_ws_candle_if_absent"):
실패목록.append("insert_ls_ws_candle_if_absent 없음")
@@ -973,7 +1042,7 @@ def run_validation() -> bool:
)
_소스필수(
실패목록, LSWebSocketPriceCache._force_close_socket,
"_graceful_unreg_all", "LS 장양보 → UNREG",
"ram_keep_codes", "LS 장양보 → UNREG 후 RAM trim(영구·보유 pin)",
)
_소스필수(
실패목록, LSWebSocketPriceCache._send_raw,
@@ -1043,6 +1112,91 @@ def run_validation() -> bool:
f"| TICK_MAX_AGE={_db원문('WS_ORDERBOOK_TICK_MAX_AGE_SEC')}"
)
# 호가 snap_time = 틱 packet_lag 와 동일 LIVE_FEED_FALLBACK 컷
from datetime import datetime as _dt
from kis_trader.engine.feed_fallback import (
is_orderbook_snap_time_stale,
live_feed_fallback_max_age_sec,
)
from kis_trader.ws.orderbook_cache import OrderbookCache, OrderbookLevel, OrderbookSnapshot
_fb = float(live_feed_fallback_max_age_sec() or 3.0)
_fresh_st = _dt.now().strftime("%Y%m%d%H%M%S")
_stale_st = (_dt.now().replace(year=2020)).strftime("%Y%m%d") + "130248"
if _fb > 0:
if is_orderbook_snap_time_stale(_stale_st):
_성공(f"호가 snap_time 낡은 시각 → stale (LIVE_FEED_FALLBACK={_fb:g}s)")
else:
실패목록.append("orderbook snap_time stale 미검출")
_실패("호가 snap_time 낡음인데 is_orderbook_snap_time_stale=False")
if not is_orderbook_snap_time_stale(_fresh_st):
_성공("호가 snap_time 신선 → 통과")
else:
실패목록.append("orderbook snap_time fresh 오탐")
_실패("호가 snap_time 신선한데 stale 오탐")
if not is_orderbook_snap_time_stale(""):
_성공("호가 snap_time 빈값 → 버리지 않음(틱 lag None 과 동일)")
else:
실패목록.append("orderbook snap_time empty 오탐")
_실패("호가 snap_time 빈값인데 stale 오탐")
_obc = OrderbookCache()
# 강제로 매우 낡은 snap 주입 후 get(3) 거부·get(0) 유지 검증
_inj = OrderbookSnapshot(
code="375500",
bids=[OrderbookLevel(74000, 10)],
asks=[OrderbookLevel(74100, 10)],
total_bid_qty=10,
total_ask_qty=10,
ts=time.time(), # 수신 ts 는 신선
source="ls_uh1",
snap_time=_stale_st,
)
with _obc._lock:
_obc._data["375500"] = _inj
if _obc.get("375500", max_age_sec=3.0) is None:
_성공("호가 get(3s): ts 신선·snap 낡음 → None (체인 실패)")
else:
실패목록.append("orderbook get snap stale pass")
_실패("호가 get(3s) 가 snap 낡은데 통과")
if _obc.get("375500", max_age_sec=0.0) is not None:
_성공("호가 get(0)=마지막 RAM: snap 낡아도 반환 (FILTER 구멍 방지)")
else:
실패목록.append("orderbook get(0) snap reject")
_실패("호가 get(0) 이 snap 때문에 None — FILTER_MAX_AGE=0 위반")
# 신선한 LS 적재는 성공
_ok = _obc.update_from_ls_hoga(
"005930",
{
"offerho1": "70000", "bidho1": "69900",
"offerrem1": "5", "bidrem1": "5",
"hotime": _dt.now().strftime("%H%M%S"),
},
source="ls_uh1",
)
if _ok is not None and _obc.get("005930", max_age_sec=3.0) is not None:
_성공("호가 LS 신선 snap → RAM 적재·get(3s) OK")
else:
실패목록.append("orderbook fresh ls commit fail")
_실패("호가 신선 LS 적재/조회 실패")
# stale body: hotime 가 지금과 3초 초과면 skip (장중 130248 은 거의 항상 skip)
# 단위: 명시적 stale snap_time 주입 경로로 이미 get 검증함. ingest skip 은 helper 로.
_파일필수(
실패목록,
"kis_trader/ws/orderbook_cache.py",
"_commit_if_fresh",
"호가 RAM snap_time 컷(틱 skip_ram 정합)",
)
_파일필수(
실패목록,
"kis_trader/engine/feed_fallback.py",
"is_orderbook_snap_time_stale",
"호가 snap lag 헬퍼 = 틱 packet_lag",
)
else:
_주의("LIVE_FEED_FALLBACK=0 — 호가 snap_time 컷 OFF (레거시)")
_파일필수(
실패목록,
"kis_trader/backtest/param_search_optuna.py",