Files
kis_bot/scripts/test_live_execution_validation.py
Your Name 5e44b86f8b 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>
2026-08-27 15:21:21 +09:00

1310 lines
58 KiB
Python
Executable File

#!/usr/bin/env python3
"""
scripts/test_live_execution_validation.py
=========================================
실매 코어 스모크 (주문·REST 연타·실WS 연결 없음).
반드시 **DB에서 읽은 실제 ON/OFF** 를 먼저 찍고, 그 값에 맞춰 기대 동작을 검사한다.
가상 호가 주입은 「DB에서 필터 ON인 전략」에만 실매와 동일하게 적용한다.
(OFF 전략에 가짜 탈락을 찍지 않음 — 헷갈림 방지)
추가로 검증:
· ws_candles source/channel 정규화 · 실매=Optuna 읽기쌍
· 갭보정 키움 REST만 (KIS REST 강제 무시)
· 영구구독 마스터 · LS DB 적재 가드 · 운영설정 UI 키
· WS close 전 구독해제(한투/LS/키움) · freeze ON · 호가 TTL 분리
· Optuna OHLC 폴백 강제 OFF · approval 공유 · SKIP_HTS DB 표기
실행 (중요 코드 수정 후 완료 보고 전 최소 1회):
python3 -u scripts/test_live_execution_validation.py
# 로그: logs/test_live_execution_validation_YYYYMMDD_HHMMSS.log
"""
from __future__ import annotations
import logging
import sys
import threading
import time
from datetime import datetime
from pathlib import Path
from typing import Any, List
from unittest.mock import MagicMock, patch
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
logging.basicConfig(level=logging.WARNING, format="[%(levelname)s] %(message)s")
전략목록 = ["MOMENTUM", "BREAKOUT", "SCALP", "TAIL"]
전략한글 = {
"MOMENTUM": "모멘텀",
"BREAKOUT": "돌파",
"SCALP": "스캘핑",
"TAIL": "꼬리(SHORT=TAIL키)",
}
def _성공(msg: str) -> None:
print(f"{msg}")
def _주의(msg: str) -> None:
print(f" ⚠️ {msg}")
def _실패(msg: str) -> None:
print(f"{msg}")
def _전략표시(sid: str) -> str:
return f"{전략한글.get(sid, sid)}[{sid}]"
def _소스필수(실패목록: List[str], obj: Any, needle: str, 라벨: str) -> None:
"""실소켓/REST 없이 소스 문자열만 검사."""
import inspect
try:
src = inspect.getsource(obj)
except (OSError, TypeError) as exc:
실패목록.append(f"{라벨} getsource 실패")
_실패(f"{라벨}: getsource 실패 ({exc})")
return
if needle not in src:
실패목록.append(f"{라벨} 누락")
_실패(f"{라벨}: `{needle[:72]}` 없음")
else:
_성공(라벨)
def _파일필수(실패목록: List[str], rel: str, needle: str, 라벨: str) -> None:
path = ROOT / rel
try:
text = path.read_text(encoding="utf-8")
except OSError as exc:
실패목록.append(f"{라벨} 파일없음")
_실패(f"{라벨}: {rel} 읽기 실패 ({exc})")
return
if needle not in text:
실패목록.append(f"{라벨} 누락")
_실패(f"{라벨}: `{needle[:72]}` 없음 ({rel})")
else:
_성공(라벨)
def _파일금지(실패목록: List[str], rel: str, needle: str, 라벨: str) -> None:
path = ROOT / rel
try:
text = path.read_text(encoding="utf-8")
except OSError as exc:
실패목록.append(f"{라벨} 파일없음")
_실패(f"{라벨}: {rel} 읽기 실패 ({exc})")
return
if needle in text:
실패목록.append(f"{라벨} 금지패턴")
_실패(f"{라벨}: `{needle[:72]}` 가 있으면 안 됨 ({rel})")
else:
_성공(라벨)
def _db원문(: str) -> str:
from kis_trader.utils.env import get_env_from_db
raw = get_env_from_db(, "")
if raw in (None, "", "None"):
return "(비어있음→코드기본값)"
return repr(str(raw))
def run_validation() -> bool:
print("=" * 78)
print("실매 엔진 검증 (DB 실제값 기준 · 호가/휩쏘/WS spill)")
print("=" * 78)
실패목록: List[str] = []
# ── 1) 모듈 로드 ───────────────────────────────────────────────
print("\n[1단계] 핵심 모듈 import")
try:
from database import TradeDB, ENV_CONFIG_KEYS
from kis_trader.strategies.momentum import MomentumStrategy
from kis_trader.strategies.breakout import BreakoutStrategy
from kis_trader.engine.orderbook_filter import orderbook_reject_for_entry
from kis_trader.engine.orderbook_env import (
load_orderbook_threshold_cfg,
orderbook_filter_enabled,
)
from kis_trader.engine.whipsaw_filter import whipsaw_filter_enabled
from kis_trader.ws.orderbook_cache import OrderbookSnapshot, OrderbookLevel
from kis_trader.engine.momentum_hts_logic import need_ob_or_history
from kis_trader.network.ws_manager import WSManager
from kis_trader.ws.kis_ws import KISWebSocketPriceCache
from kis_trader.execution.order_manager import OrderManager
from kis_trader.utils.env import get_env_bool, get_env_from_db, invalidate_merged_env_cache
from kis_trader.utils.live_portfolio_common import (
resolve_live_universe_history_source,
)
from kis_trader.web.live_config_schema import build_live_config_groups
import kis_trader.engine.trigger_eval_collect as 트리거수집
import inspect
invalidate_merged_env_cache()
_성공("전략/호가/휩쏘/WSManager/체결매니저 로드 OK")
except Exception as exc:
_실패(f"import 실패: {exc}")
return False
db = TradeDB()
# ── 2) DB 진입필터 ON/OFF 표 (핵심) ─────────────────────────────
print("\n[2단계] DB에 저장된 「진입」필터 스위치 (이게 실매 기준)")
print(" · 진입호가필터 = {전략}_ORDERBOOK_FILTER_ENABLED")
print(" · 진입휩쏘필터 = {전략}_WHIPSAW_FILTER_ENABLED")
print(" · 청산호가(EXIT/STOP)는 「진입」과 별개 — 아래 3단계에서 표기")
print("-" * 78)
진입호가_켜진전략: List[str] = []
for sid in 전략목록:
호가키 = f"{sid}_ORDERBOOK_FILTER_ENABLED"
휩쏘키 = f"{sid}_WHIPSAW_FILTER_ENABLED"
호가_db = _db원문(호가키)
휩쏘_db = _db원문(휩쏘키)
호가_실매적용 = orderbook_filter_enabled(sid)
휩쏘_실매적용 = whipsaw_filter_enabled(sid)
임계 = load_orderbook_threshold_cfg(sid, {})
if 호가_실매적용:
진입호가_켜진전략.append(sid)
print(f" {_전략표시(sid)}")
print(
f" 진입 호가필터: {'켜짐(ON)' if 호가_실매적용 else '꺼짐(OFF)'} "
f"| DB {호가키}={호가_db}"
)
print(
f" 진입 휩쏘필터: {'켜짐(ON)' if 휩쏘_실매적용 else '꺼짐(OFF)'} "
f"| DB {휩쏘키}={휩쏘_db}"
)
if 호가_실매적용:
print(
f" 호가 임계(DB): 스프레드상한={임계.get('max_spread_pct')}% "
f"| 잔량비하한={임계.get('min_bid_ask_ratio')}"
)
else:
print(" → 실매 진입 시 호가 탈락검사 안 함 (스냅이 있어도 통과)")
print("-" * 78)
print(
f" 요약: 진입호가필터 ON 전략 = "
f"{', '.join(_전략표시(s) for s in 진입호가_켜진전략) or '(없음 — 전부 OFF)'}"
)
print(
f" 참고 전역키(전략키 없을 때 안 씀/호가는 전략키만): "
f"ORDERBOOK_FILTER_ENABLED={_db원문('ORDERBOOK_FILTER_ENABLED')} "
f"| WHIPSAW_FILTER_ENABLED={_db원문('WHIPSAW_FILTER_ENABLED')}"
)
# ── 3) 청산 호가컷 (진입과 분리) ────────────────────────────────
print("\n[3단계] 「청산」호가컷 DB (진입필터와 다름)")
try:
돌파 = BreakoutStrategy(db=db, client=None, ws=None, order_mgr=None)
print(
f" {_전략표시('BREAKOUT')} "
f"익절호가매도={'ON' if getattr(돌파, 'exit_ob_enabled', False) else 'OFF'} "
f"(DB {_db원문('BREAKOUT_EXIT_OB_ENABLED')}) | "
f"손절호가={'ON' if getattr(돌파, 'stop_ob_enabled', False) else 'OFF'} "
f"(DB {_db원문('BREAKOUT_STOP_OB_ENABLED')})"
)
mom_exit = get_env_bool("MOMENTUM_EXIT_OB_ENABLED", False)
mom_stop = get_env_bool("MOMENTUM_STOP_OB_ENABLED", False)
print(
f" {_전략표시('MOMENTUM')} "
f"익절호가매도={'ON' if mom_exit else 'OFF'} "
f"(DB {_db원문('MOMENTUM_EXIT_OB_ENABLED')}) | "
f"손절호가={'ON' if mom_stop else 'OFF'} "
f"(DB {_db원문('MOMENTUM_STOP_OB_ENABLED')}) | "
f"호가추적필요={need_ob_or_history({'exit_ob_enabled': mom_exit, 'stop_ob_enabled': mom_stop})}"
)
_성공("청산 호가컷은 진입 호가필터와 독립적으로 DB 값을 읽음")
except Exception as exc:
_실패(f"청산 호가컷 검증 오류: {exc}")
return False
# ── 4) 가상호가 시뮬 — DB ON인 전략만 ────────────────────────────────
print("\n[4단계] 가상 호가 주입 시뮬 (DB에서 진입호가필터 ON인 전략만)")
print(" 우량=스프레드 좁고 매수잔량 우세 | 위험=스프레드 과다")
print(" ※ 필터 OFF 전략은 「실매와 같이 탈락 안 함」만 확인하고 가짜 차단 로그를 안 찍음")
def _가짜호가(스프레드퍼센트: float, 매수잔량: int, 매도잔량: int) -> OrderbookSnapshot:
현재가 = 10000
매도1 = int(현재가 + (현재가 * 스프레드퍼센트 / 100.0))
매수1 = int(현재가)
매수호가 = [
OrderbookLevel(price=매수1 - i * 10, qty=int(매수잔량 / 10))
for i in range(10)
]
매도호가 = [
OrderbookLevel(price=매도1 + i * 10, qty=int(매도잔량 / 10))
for i in range(10)
]
return OrderbookSnapshot(
code="005930",
bids=매수호가,
asks=매도호가,
total_bid_qty=매수잔량,
total_ask_qty=매도잔량,
ts=1234567890.0,
source="kiwoom_0d",
)
우량호가 = _가짜호가(0.1, 6000, 2000)
위험호가 = _가짜호가(3.5, 500, 50000)
for sid in 전략목록:
필터켜짐 = orderbook_filter_enabled(sid)
if not 필터켜짐:
# OFF면 엔진에 스냅을 넣어도 실매 경로와 같이 탈락코드 없음이 정상
탈락코드, _ = orderbook_reject_for_entry(
{
"_backtest_orderbook_snapshot": 위험호가,
"slot_money": 10_000_000,
},
sid,
current_price=10000.0,
)
if 탈락코드:
실패목록.append(f"{sid}: 필터 OFF인데 탈락코드={탈락코드}")
_실패(
f"{_전략표시(sid)} DB=OFF 인데도 탈락({탈락코드}) — 버그 가능"
)
else:
_성공(
f"{_전략표시(sid)} 진입호가필터 OFF → 위험호가를 넣어도 탈락 없음(실매와 동일)"
)
continue
우량탈락, _ = orderbook_reject_for_entry(
{"_backtest_orderbook_snapshot": 우량호가, "slot_money": 10_000_000},
sid,
current_price=10000.0,
)
위험탈락, 위험사유 = orderbook_reject_for_entry(
{"_backtest_orderbook_snapshot": 위험호가, "slot_money": 10_000_000},
sid,
current_price=10000.0,
)
if 우량탈락:
실패목록.append(f"{sid}: 우량호가 탈락 {우량탈락}")
_실패(f"{_전략표시(sid)} 우량호가인데 탈락: {우량탈락}")
else:
_성공(f"{_전략표시(sid)} 우량호가 → 통과")
if not 위험탈락:
실패목록.append(f"{sid}: 위험호가 미차단")
_실패(f"{_전략표시(sid)} 위험호가 미차단 (필터 ON인데 통과함)")
else:
_성공(f"{_전략표시(sid)} 위험호가 → 차단 ({위험탈락}: {위험사유})")
# ── 5) WS 단절 · 유니버스 소스 (DB) ────────────────────────────────
print("\n[5단계] 호가스냅 없음(None) · 유니버스 소스 (DB)")
print(" 실매 후보=키움 RAM. history 는 백테 재생용.")
print(" kiwoom→target_candidates_history / ls→ls_candidates_history")
for sid in 전략목록:
탈락, 사유 = orderbook_reject_for_entry(
{"slot_money": 10_000_000},
sid,
current_price=10000.0,
)
필터켜짐 = orderbook_filter_enabled(sid)
if not 필터켜짐:
if 탈락:
실패목록.append(f"{sid}: 필터 OFF인데 스냅None 탈락={탈락}")
_실패(f"{_전략표시(sid)} 필터 OFF인데 스냅없음 탈락({탈락})")
else:
_성공(f"{_전략표시(sid)} 필터 OFF + 스냅없음 → 통과(막지 않음)")
continue
if 탈락 == "탈락-호가없음":
_성공(f"{_전략표시(sid)} 필터 ON + 스냅없음 → 차단({탈락})")
elif 탈락 is None:
실패목록.append(f"{sid}: 필터 ON인데 스냅없음 Fail-Open")
_실패(f"{_전략표시(sid)} 필터 ON인데 스냅없음 통과 — REJECT_IF_EMPTY 확인")
else:
실패목록.append(f"{sid}: 스냅없음 예상외 탈락={탈락}")
_실패(f"{_전략표시(sid)} 스냅없음 예상외({탈락}: {사유})")
print(" B안: 저장TTL보다 오래된 RAM 호가도 필터 ON이면 검사")
class _오래된호가WS:
def __init__(self, snap: OrderbookSnapshot) -> None:
self.snap = snap
def get_orderbook_snapshot(self, code: str, max_age_sec: float = 3.0):
if max_age_sec > 0 and (time.time() - float(self.snap.ts or 0)) > max_age_sec:
return None
return self.snap
_stale_ws = _오래된호가WS(위험호가)
for sid in 전략목록:
if not orderbook_filter_enabled(sid):
continue
탈락, _ = orderbook_reject_for_entry(
{
"_orderbook_ws": _stale_ws,
"_orderbook_code": "005930",
"slot_money": 10_000_000,
},
sid,
current_price=10000.0,
)
if 탈락:
_성공(f"{_전략표시(sid)} 오래된 RAM 호가 → 차단({탈락}) B안 OK")
else:
실패목록.append(f"{sid}: 오래된 호가 Fail-Open")
_실패(f"{_전략표시(sid)} 오래된 RAM 호가인데 통과 — FILTER_MAX_AGE 확인")
for sid in 전략목록:
hist = resolve_live_universe_history_source(sid) # DB UNIVERSE_SOURCE
univ = (get_env_from_db(f"{sid}_UNIVERSE_SOURCE", "") or "").strip() or "(기본)"
print(
f" {_전략표시(sid)} 유니버스소스={univ} | 이력테이블쪽={hist}"
)
# ── 6) 수집 스위치 (필터와 분리) ────────────────────────────────
print("\n[6단계] 호가/프로그램 「수집」스위치 (진입필터와 별개 · DB 연동만 확인)")
with patch(
"kis_trader.engine.trigger_eval_collect.get_env_bool"
) as mock_bool, patch(
"kis_trader.engine.trigger_eval_collect.get_env_from_db", return_value=""
), patch(
"kis_trader.engine.trigger_eval_collect.trigger_eval_save_master_enabled",
return_value=True,
):
mock_bool.return_value = True
if not 트리거수집.orderbook_collect_enabled({}, "MOMENTUM"):
실패목록.append("수집스위치 ON 무시")
_실패("호가수집이 DB ON을 무시함")
mock_bool.return_value = False
if 트리거수집.orderbook_collect_enabled({}, "MOMENTUM"):
실패목록.append("수집스위치 OFF 무시")
_실패("호가수집이 DB OFF를 무시함")
else:
_성공("수집 스위치가 get_env_bool 따라감 (하드코딩 없음)")
# ── 7) WS spill ────────────────────────────────────────────────
print("\n[7단계] 구독 spill (한도초과→다음 증권사, sleep 없음)")
try:
src = inspect.getsource(KISWebSocketPriceCache.subscribe)
if "return False" not in src or "return True" not in src:
실패목록.append("kis subscribe bool 아님")
_실패("kis_ws.subscribe bool 반환 패턴 없음")
else:
_성공("한투(kis) subscribe → 성공/한도초과 bool")
class _가짜DB:
pass
허브 = WSManager(db=_가짜DB(), kis_client=MagicMock())
틱체인 = 허브._build_subscribe_chain("tick")
호가체인 = 허브._build_subscribe_chain("ob")
if not 틱체인 or 틱체인[-1] != "ls":
실패목록.append(f"틱체인 말단 ls 아님: {틱체인}")
_실패(f"틱 spill 체인 이상: {틱체인}")
else:
_성공(f"틱 구독 우선순위={틱체인} (마지막=3차 LS)")
if not 호가체인 or 호가체인[-1] != "ls":
실패목록.append(f"호가체인 말단 ls 아님: {호가체인}")
_실패(f"호가 spill 체인 이상: {호가체인}")
else:
_성공(f"호가 구독 우선순위={호가체인}")
# 한투 한도 → LS
한투 = MagicMock()
한투.subscribe = MagicMock(return_value=False)
한투.is_active = True
한투._subscribed = set()
한투._sub_lock = threading.Lock()
허브.ws_cache = 한투
허브._kiwoom_ws = None
엘에스 = MagicMock()
엘에스.is_connected = MagicMock(return_value=True)
엘에스.subscribe = MagicMock(return_value=True)
엘에스._subscribed = set()
엘에스._us_subscribed = set()
엘에스._sub_lock = threading.Lock()
허브._get_ls_ws = MagicMock(return_value=엘에스)
home = 허브._subscribe_tick_prefer_or_spill("005930", "kis")
라벨 = 허브.get_tick_feed_label("005930")
if home != "ls" or "spill" not in 라벨:
실패목록.append(f"한투→LS spill 실패 {home}/{라벨}")
_실패(f"한투 한도 spill 실패: {home} / {라벨}")
else:
_성공(f"한투 한도초과 → 즉시 LS 이관 (시세라벨={라벨})")
# 키움 한도 → 한투
허브2 = WSManager(db=_가짜DB(), kis_client=MagicMock())
키움 = MagicMock()
키움.subscribe = MagicMock(return_value=False)
키움.is_connected = MagicMock(return_value=True)
키움._subscribed = set()
키움._sub_lock = threading.Lock()
한투2 = MagicMock()
한투2.subscribe = MagicMock(return_value=True)
한투2.is_active = True
한투2._subscribed = set()
한투2._sub_lock = threading.Lock()
허브2._kiwoom_ws = 키움
허브2.ws_cache = 한투2
허브2._get_ls_ws = MagicMock(return_value=None)
home2 = 허브2._subscribe_tick_prefer_or_spill("000660", "kiwoom")
라벨2 = 허브2.get_tick_feed_label("000660")
if home2 != "kis" or "kis" not in 라벨2 or "spill" not in 라벨2:
실패목록.append(f"키움→한투 spill 실패 {home2}/{라벨2}")
_실패(f"키움 한도 spill 실패: {home2}/{라벨2}")
else:
_성공(f"키움 한도초과 → 즉시 한투 이관 (시세라벨={라벨2})")
주문 = OrderManager(client=MagicMock(), db=MagicMock())
주문.tick_feed_label_provider = 허브2.get_tick_feed_label
주문.ob_feed_label_provider = getattr(허브2, "get_ob_feed_label", None)
mm줄 = 주문._tick_feed_mm_line("000660")
if "시세:" not in mm줄 or "호가:" not in mm줄:
실패목록.append(f"MM 시세줄 이상 {mm줄}")
_실패(f"매터모스트 시세/호가 표기 이상: {mm줄}")
else:
_성공(f"체결 알림 시세·호가 한 줄 예: {mm줄}")
# 메인 2초 실패 → 2차 get_price (RAM 없음=실패, REST 없음)
허브_fb = WSManager(db=_가짜DB(), kis_client=MagicMock())
키움_stale = MagicMock()
키움_stale.get_price = MagicMock(return_value=None)
한투_ok = MagicMock()
한투_ok.get_price = MagicMock(return_value={"stck_prpr": "12345"})
허브_fb._kiwoom_ws = 키움_stale
허브_fb.ws_cache = 한투_ok
허브_fb._get_ls_ws = MagicMock(return_value=None)
px_fb = 허브_fb.get_price("005930")
lab_fb = 허브_fb.get_tick_feed_label("005930")
if not isinstance(px_fb, dict) or str(px_fb.get("stck_prpr") or "") != "12345":
실패목록.append(f"get_price 2차 폴백 실패 {px_fb}")
_실패(f"메인 None 인데 2차 현재가를 안 씀: {px_fb}")
elif "kis" not in str(lab_fb):
실패목록.append(f"2차 시세라벨 이상 {lab_fb}")
_실패(f"2차 폴백 라벨에 kis 없음: {lab_fb}")
else:
_성공(f"메인 실패→2차 시세 {px_fb.get('stck_prpr')} 라벨={lab_fb}")
허브_fb3 = WSManager(db=_가짜DB(), kis_client=MagicMock())
키움_n = MagicMock(); 키움_n.get_price = MagicMock(return_value=None)
한투_n = MagicMock(); 한투_n.get_price = MagicMock(return_value=None)
엘에스_ok = MagicMock(); 엘에스_ok.get_price = MagicMock(return_value={"stck_prpr": "777"})
허브_fb3._kiwoom_ws = 키움_n
허브_fb3.ws_cache = 한투_n
허브_fb3._get_ls_ws = MagicMock(return_value=엘에스_ok)
px3 = 허브_fb3.get_price("005930")
lab3 = 허브_fb3.get_tick_feed_label("005930")
if not isinstance(px3, dict) or str(px3.get("stck_prpr") or "") != "777":
실패목록.append(f"get_price 3차 폴백 실패 {px3}")
_실패(f"1·2차 None 인데 3차 LS 를 안 씀: {px3}")
elif "ls" not in str(lab3):
실패목록.append(f"3차 시세라벨 이상 {lab3}")
_실패(f"3차 폴백 라벨에 ls 없음: {lab3}")
else:
_성공(f"1·2차 실패→3차 LS {px3.get('stck_prpr')} 라벨={lab3}")
from kis_trader.engine.feed_fallback import merge_ticks_time_axis_fallback
merged_empty = merge_ticks_time_axis_fallback(
[
{"tick_time": "20260819120000", "source": "kiwoom", "price": 1, "_lag_sec": 9},
{"tick_time": "20260819120000", "source": "kis", "price": 2, "_lag_sec": 8},
],
main_src="kiwoom",
max_lag_sec=2.0,
)
if merged_empty:
실패목록.append(f"늦은 보조를 씀 {merged_empty}")
_실패("메인·보조 둘 다 2초 초과인데 틱을 남김")
else:
_성공("메인·보조 둘 다 늦으면 그 초 틱 없음(OHLC 메우지 않음)")
허브3 = WSManager(db=_가짜DB(), kis_client=MagicMock())
한투3 = MagicMock()
한투3.subscribe = MagicMock(return_value=False)
한투3.is_active = True
허브3.ws_cache = 한투3
허브3._kiwoom_ws = None
허브3._get_ls_ws = MagicMock(return_value=엘에스)
허브3._subscribe_spill_enabled = lambda: False # type: ignore[method-assign]
home3 = 허브3._subscribe_tick_prefer_or_spill("035420", "kis")
if home3 is not None:
실패목록.append("spill OFF인데 이관됨")
_실패(f"WS_SUBSCRIBE_SPILL 꺼도 이관됨: {home3}")
else:
_성공("구독 spill 스위치 OFF → 한도 시 거절(예전과 동일)")
except Exception as exc:
_실패(f"spill 검증 오류: {exc}")
import traceback
traceback.print_exc()
return False
# ── 8) env / 운영설정 UI ───────────────────────────────────────
print("\n[8단계] 운영설정·ENV 키 (LS WS / spill / 영구구독 / 갭보정)")
for in (
"LS_WS_ENABLED",
"WS_SUBSCRIBE_SPILL",
"WS_TICK_SUBSCRIBE_CHAIN",
"WS_OB_SUBSCRIBE_CHAIN",
"PERMANENT_SUBSCRIBE_ENABLED",
"WS_GAP_FILL_KIS_FALLBACK",
"PERM_LS_FILL_BARS",
"PERM_LS_FILL_SLEEP_MIN",
"PERM_LS_FILL_SLEEP_MAX",
"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",
"LS_WS_ORDERBOOK_SAVE",
):
if not in ENV_CONFIG_KEYS:
실패목록.append(f"ENV 누락 {}")
_실패(f"database.ENV_CONFIG_KEYS 에 {} 없음")
else:
_성공(f"ENV 등록됨: {} (현재값={_db원문()})")
print(
f" 현재 실매: 구독spill={'ON' if get_env_bool('WS_SUBSCRIBE_SPILL', True) else 'OFF'} | "
f"LS WS기동={'ON' if get_env_bool('LS_WS_ENABLED', False) else 'OFF'} | "
f"영구구독마스터={'ON' if get_env_bool('PERMANENT_SUBSCRIBE_ENABLED', True) else 'OFF'} | "
f"틱메인={_db원문('LIVE_TICK_PROVIDER')} | "
f"CANDLE_SOURCE={_db원문('CANDLE_SOURCE')}"
)
시세섹션 = next(
(g for g in build_live_config_groups() if g.get("id") == "ws_feed_settings"),
None,
)
if not 시세섹션:
실패목록.append("운영설정 시세섹션 없음")
_실패("운영설정「시세 소스」섹션 없음")
else:
ui키들 = {f.get("key") for f in 시세섹션.get("fields") or []}
for need in (
"LS_WS_ENABLED",
"WS_SUBSCRIBE_SPILL",
"PERMANENT_SUBSCRIBE_ENABLED",
"WS_GAP_FILL_KIS_FALLBACK",
"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",
"CANDLE_GARBAGE_FALLBACK",
"WS_ORDERBOOK_SAVE_KIS",
):
if need not in ui키들:
실패목록.append(f"UI 누락 {need}")
_실패(f"운영설정 UI에 {need} 없음")
else:
_성공(f"운영설정 UI 필드 있음: {need}")
# ── 9) 봉 source/channel · 실매=Optuna 읽기 ───────────────────────
print("\n[9단계] ws_candles source/channel · 실매=백테 읽기쌍")
try:
from kis_trader.ws.candle_series import (
dedupe_by_read_pairs,
live_read_pairs,
normalize_source_channel,
)
from kis_trader.backtest.bt_candle_source import resolve_bt_read_pairs
from kis_trader.ws.kis_ws import CandleAggregator
for raw, expect in (
(("rest", ""), ("kiwoom", "rest")),
(("kw_rest", ""), ("kiwoom", "rest")),
(("rollup_1m", ""), ("kiwoom", "rollup")),
(("ws", ""), ("kis", "ws")),
(("kiwoom", ""), ("kiwoom", "ws")),
(("kis", "ws"), ("kis", "ws")),
):
got = normalize_source_channel(raw[0], raw[1])
if got != expect:
실패목록.append(f"normalize {raw}{got}")
_실패(f"normalize_source_channel{raw} = {got} (기대 {expect})")
else:
_성공(f"정규화 {raw[0]!r}/{raw[1]!r}{got}")
pairs = live_read_pairs()
bt_pairs = resolve_bt_read_pairs()
if pairs != bt_pairs:
실패목록.append(f"실매≠Optuna 읽기쌍 {pairs} vs {bt_pairs}")
_실패(f"live_read_pairs≠resolve_bt_read_pairs: {pairs} / {bt_pairs}")
else:
_성공(f"실매=Optuna 읽기쌍 {pairs}")
if pairs[0][1] != "ws" or ("kiwoom", "rest") not in pairs:
실패목록.append(f"읽기쌍 규칙 위반 {pairs}")
_실패(f"메인은 WS, 구멍은 kiwoom+rest 여야 함: {pairs}")
elif ("ls", "ws") not in pairs or len(pairs) < 5:
실패목록.append(f"읽기쌍 길이 {pairs}")
_실패(f"메인WS+2차WS+LS+rest+rollup 5쌍이어야 함: {pairs}")
else:
_성공("메인 channel=ws, 구멍에 kiwoom+rest 포함")
# 같은 candle_time: 메인 WS 승, rest 구멍만 채움
rows = [
{
"candle_time": "202608131000",
"open": 1, "high": 1, "low": 1, "close": 100,
"volume": 1, "source": "kiwoom", "channel": "rest",
},
{
"candle_time": "202608131000",
"open": 2, "high": 2, "low": 2, "close": 200,
"volume": 2, "source": "kiwoom", "channel": "ws",
},
{
"candle_time": "202608131001",
"open": 3, "high": 3, "low": 3, "close": 300,
"volume": 3, "source": "kiwoom", "channel": "rest",
},
]
deduped = dedupe_by_read_pairs(
rows, (("kiwoom", "ws"), ("kiwoom", "rest")),
)
by_t = {r["candle_time"]: r["close"] for r in deduped}
if by_t.get("202608131000") != 200:
실패목록.append("dedupe WS 우선 실패")
_실패(f"같은 시각에 WS가 이겨야 함: {by_t}")
elif by_t.get("202608131001") != 300:
실패목록.append("dedupe rest 구멍 실패")
_실패(f"구멍 rest 유지 실패: {by_t}")
else:
_성공("dedupe: WS 우선·rest 구멍만 채움")
# 갭보정 쓰기 라벨 (소스 코드)
fill_src = inspect.getsource(CandleAggregator.fill_gap_from_rest)
if '"source": "kiwoom"' not in fill_src or '"channel": "rest"' not in fill_src:
실패목록.append("fill_gap source/channel 아님")
_실패("fill_gap_from_rest 가 kiwoom+rest 로 안 씀")
else:
_성공("갭보정 쓰기 = source=kiwoom, channel=rest")
if not hasattr(CandleAggregator, "_bar_key"):
실패목록.append("CandleAggregator._bar_key 없음")
_실패("RAM 키가 (code,tf,source,channel) 아님")
else:
k = CandleAggregator._bar_key("005930", 1, "rest", "")
if k != ("005930", 1, "kiwoom", "rest"):
실패목록.append(f"_bar_key {k}")
_실패(f"_bar_key 정규화 실패: {k}")
else:
_성공(f"RAM _bar_key 정규화 OK {k}")
except Exception as exc:
실패목록.append(f"봉소스 검증 오류: {exc}")
_실패(f"봉 source/channel 검증 오류: {exc}")
import traceback
traceback.print_exc()
# ── 10) 갭보정 KIS REST 금지 · 영구구독 갭제외 ─────────────────
print("\n[10단계] 갭보정=키움만 · 영구구독은 ls_ws_candles")
try:
import permanent_subs as ps
fill_gap_src = inspect.getsource(WSManager._fill_gap_for_code)
if "get_minute_chart" in fill_gap_src:
실패목록.append("갭보정에 KIS get_minute_chart 잔존")
_실패("WSManager._fill_gap_for_code 에 KIS REST 호출이 남아 있음")
else:
_성공("갭보정 경로에 KIS get_minute_chart 없음")
if "WS_GAP_FILL_KIS_FALLBACK ON 무시" not in fill_gap_src:
실패목록.append("KIS fallback 무시 로그 없음")
_실패("WS_GAP_FILL_KIS_FALLBACK ON 무시 가드 문구 없음")
else:
_성공("WS_GAP_FILL_KIS_FALLBACK ON 이어도 호출 안 함(가드 있음)")
sync_src = inspect.getsource(WSManager._sync_permanent_to_ls)
if "subscribe_master_enabled" not in sync_src:
실패목록.append("permanent sync 마스터 미적용")
_실패("_sync_permanent_to_ls 가 마스터 스위치를 안 봄")
else:
_성공("영구구독→LS sync 가 마스터 OFF 시 빈 집합")
if not hasattr(WSManager, "_sync_feed_fallback_to_ls"):
실패목록.append("_sync_feed_fallback_to_ls 없음")
_실패("LS 3차 폴백 구독 owner=_feed_fallback 없음")
else:
fb_src = inspect.getsource(WSManager._sync_feed_fallback_to_ls)
if "LS_FEED_FALLBACK_SUBSCRIBE" not in fb_src:
실패목록.append("LS_FEED_FALLBACK_SUBSCRIBE 미적용")
_실패("_sync_feed_fallback_to_ls 가 LS_FEED_FALLBACK_SUBSCRIBE 를 안 봄")
else:
_성공("LS 3차 폴백 구독 _sync_feed_fallback_to_ls")
rec_src = inspect.getsource(WSManager._reconcile_split_subscriptions)
if "_finalize_ls_subscriptions" not in rec_src:
실패목록.append("split reconcile LS finalize 없음")
_실패("_reconcile_split_subscriptions 가 _finalize_ls_subscriptions 를 안 부름")
else:
_성공("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:
실패목록.append("한투 호가 메인 폴백")
_실패("WSManager.start 가 kis_ws_ob=메인 폴백(ASP0 합산)을 함")
elif "kis_ws_ob = None" not in start_src:
실패목록.append("kis_ws_ob None 미설정")
_실패("2키 실패 시 kis_ws_ob=None 이 없음")
else:
_성공("한투 호가: 2키 전용, 메인 ASP0 폴백 없음")
try_ob = inspect.getsource(WSManager._try_subscribe_vendor_ob)
if "ob is self.ws_cache" not in try_ob:
실패목록.append("OB 구독 메인 거절 없음")
_실패("_try_subscribe_vendor_ob 가 메인=OB 를 거절하지 않음")
else:
_성공("KIS 호가 구독은 전용 세션만")
from unittest.mock import patch as _patch
from kis_trader.engine.feed_fallback import bar_is_garbage, live_bar_is_garbage
_ct = "202608191000"
with _patch(
"kis_trader.engine.feed_fallback.candle_garbage_fallback_enabled",
return_value=True,
), _patch(
"kis_trader.engine.feed_fallback.live_feed_fallback_max_age_sec",
return_value=3.0,
):
if not bar_is_garbage([], candle_time=_ct, tf_min=1, source="kiwoom"):
실패목록.append("쓰레기 0건 실패")
_실패("bar_is_garbage 틱 0건은 구멍이어야 함")
else:
_성공("분봉 쓰레기: 그 분 틱 0건 = 구멍")
stale_in = [{
"source": "kiwoom",
"tick_time": "20260819100000",
"tick_time_raw": "20260819100000",
}]
if not bar_is_garbage(stale_in, candle_time=_ct, tf_min=1, source="kiwoom"):
실패목록.append("쓰레기 stale 실패")
_실패("bar_is_garbage 전부 봉끝 대비 나이초과면 구멍이어야 함")
else:
_성공("분봉 쓰레기: 전부 봉끝 대비 나이초과 = 구멍")
fresh = [{
"source": "kiwoom",
"tick_time": "20260819100058",
"tick_time_raw": "20260819100058",
}]
if bar_is_garbage(fresh, candle_time=_ct, tf_min=1, source="kiwoom"):
실패목록.append("신선 봉을 쓰레기로")
_실패("봉끝 2초 전 틱은 유지여야 함")
else:
_성공("분봉 쓰레기: 봉끝 직전 틱이면 유지")
other = [{
"source": "kiwoom",
"tick_time": "20260819100000",
"tick_time_raw": "20260819100000",
}]
if live_bar_is_garbage(other, candle_time=_ct, tf_min=1, source="ls"):
실패목록.append("LS 봉 오판")
_실패("실매 링에 LS 틱 없으면 LS 봉을 버리면 안 됨")
else:
_성공("실매 링: 해당 소스 틱 없으면 봉 유지")
from kis_trader.ws.candle_series import pick_bar_by_read_pairs
kis_ok = [{
"source": "kis",
"tick_time": "20260819100058",
"tick_time_raw": "20260819100058",
}]
picked = pick_bar_by_read_pairs(
[
{"candle_time": _ct, "close": 1, "source": "kiwoom", "channel": "ws"},
{"candle_time": _ct, "close": 2, "source": "kis", "channel": "ws"},
],
(("kiwoom", "ws"), ("kis", "ws")),
ticks=stale_in + kis_ok, tf_min=1, missing_policy="hole", live_cover=False,
)
if not picked or int(picked.get("close") or 0) != 2:
실패목록.append("pick 2차 실패")
_실패(f"메인 쓰레기면 2차 봉 통째: {picked}")
else:
_성공("pick_bar: 메인 쓰레기 → 2차 봉 통째")
from kis_trader.ws.ls_ws import LSWebSocketPriceCache
ls_msg_src = inspect.getsource(LSWebSocketPriceCache._on_message)
if '_tick_recorder and get_env_bool("LS_WS_TICK_SAVE"' in ls_msg_src:
실패목록.append("LS 틱콜백이 TICK_SAVE에 묶임")
_실패("LS_WS_TICK_SAVE OFF 가 호가 틱동기 콜백까지 끔")
elif "_tick_recorder" not in ls_msg_src:
실패목록.append("LS _tick_recorder 없음")
_실패("LS _on_message 에 _tick_recorder 없음")
else:
_성공("LS 틱콜백은 TICK_SAVE와 분리(호가 적재 가능)")
gap_src = inspect.getsource(WSManager._enqueue_ls_gap_fill)
if "LS_GAP_FILL_CANDIDATES" not in gap_src:
실패목록.append("LS_GAP_FILL_CANDIDATES 가드 없음")
_실패("후보 LS 갭 REST 기본 OFF 가드 없음")
else:
_성공("후보 LS 갭 REST 는 LS_GAP_FILL_CANDIDATES 가드")
# 갭 집합에 perm 합집합 금지 (후보|보유만)
recon_src = inspect.getsource(WSManager._reconcile_split_subscriptions)
if "_gap_refill_codes = set(kis_want) | set(kw_want) | perm" in recon_src:
실패목록.append("갭보정에 영구구독 포함")
_실패("_gap_refill_codes 가 영구구독(perm)을 포함함 — ls_ws_candles 전용이어야 함")
elif "_gap_refill_codes = set(kis_want) | set(kw_want)" not in recon_src:
실패목록.append("갭보정 집합 패턴 변경")
_주의("_reconcile_split_subscriptions 갭보정 집합 패턴이 예상과 다름 — 수동 확인")
else:
_성공("자동 갭보정 집합 = 후보·보유만 (영구구독 제외)")
if not ps.subscribe_master_enabled():
_주의("DB PERMANENT_SUBSCRIBE_ENABLED=OFF (행 유지·구독만 끔)")
else:
_성공("영구구독 마스터 ON")
if not ps.should_persist_ls("005930", {"005930", "069500"}):
실패목록.append("should_persist_ls True 기대")
_실패("영구구독 코드인데 LS 봉/VI 가드 False")
else:
_성공("should_persist_ls: 영구구독 코드만 True (봉·VI)")
if ps.should_persist_ls("999999", {"005930"}):
실패목록.append("should_persist_ls False 기대")
_실패("비영구 코드인데 LS 봉/VI 적재 허용")
else:
_성공("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 없음")
_실패("영구구독 확정봉 INSERT IGNORE 헬퍼 없음")
else:
_성공("insert_ls_ws_candle_if_absent 존재")
if not hasattr(ps, "fill_ls_candles_from_kiwoom"):
실패목록.append("fill_ls_candles_from_kiwoom 없음")
_실패("영구구독 확정봉 가져오기 헬퍼 없음")
else:
_성공("fill_ls_candles_from_kiwoom 존재 (키움→ls_ws_candles)")
except Exception as exc:
실패목록.append(f"갭/영구구독 검증 오류: {exc}")
_실패(f"갭보정·영구구독 검증 오류: {exc}")
import traceback
traceback.print_exc()
# ── 11) DB 스키마 channel · UNIQUE ─────────────────────────────
print("\n[11단계] DB ws_candles.channel · UNIQUE(source,channel)")
try:
cols = {r["Field"] for r in db.conn.execute("SHOW COLUMNS FROM ws_candles").fetchall()}
if "channel" not in cols:
실패목록.append("ws_candles.channel 없음")
_실패("ws_candles.channel 컬럼 없음")
else:
_성공("ws_candles.channel 컬럼 있음")
idx = db.conn.execute(
"SHOW INDEX FROM ws_candles WHERE Key_name='uq_candle'"
).fetchall()
idx_cols = [r["Column_name"] for r in (idx or [])]
want = ["code", "timeframe", "candle_time", "source", "channel"]
if idx_cols != want:
실패목록.append(f"uq_candle={idx_cols}")
_실패(f"UNIQUE uq_candle 기대 {want}, 실제 {idx_cols}")
else:
_성공(f"UNIQUE uq_candle={idx_cols}")
dist = db.conn.execute(
"SELECT source, channel, COUNT(*) AS n FROM ws_candles "
"GROUP BY source, channel ORDER BY n DESC"
).fetchall()
print(" 현재 source/channel 분포:")
for r in dist or []:
print(f" {r['source']}+{r['channel']}: {int(r['n']):,}")
bad = [
r for r in (dist or [])
if str(r["source"]) in ("rest", "kw_rest", "ws", "rollup_1m", "rollup")
]
if bad:
실패목록.append(f"레거시 source 잔존 {bad}")
_실패(f"레거시 source 라벨 잔존: {bad}")
else:
_성공("레거시 source=rest/ws/rollup_1m 잔존 없음")
except Exception as exc:
실패목록.append(f"스키마 검증 오류: {exc}")
_실패(f"ws_candles 스키마 검증 오류: {exc}")
# ── 12) WS close 전 구독해제 (실소켓 없음 · 소스만) ──────────────
print("\n[12단계] WS 종료·양보 전 구독해제 (한투 공문 정상 케이스)")
print(" 소켓만 close 금지. 실WS/approval REST 호출 없음.")
try:
from kis_trader.ws.kis_ws_overseas import KISOverseasWebSocket
from kis_trader.ws.kiwoom_ws import KiwoomWebSocketPriceCache
from kis_trader.ws.ls_ws import LSWebSocketPriceCache
_소스필수(
실패목록, KISWebSocketPriceCache.stop,
"_unsubscribe_all_before_close", "한투 국내 stop → 구독해제",
)
_소스필수(
실패목록, KISWebSocketPriceCache._force_close_socket,
"_unsubscribe_all_before_close", "한투 국내 장양보 → 구독해제",
)
_소스필수(
실패목록, KISOverseasWebSocket.stop,
"_unsubscribe_all_before_close", "한투 해외 stop → 구독해제",
)
_소스필수(
실패목록, KISOverseasWebSocket._force_close_socket,
"_unsubscribe_all_before_close", "한투 해외 장양보 → 구독해제",
)
_소스필수(
실패목록, LSWebSocketPriceCache.stop,
"_graceful_unreg_all", "LS stop → UNREG",
)
_소스필수(
실패목록, LSWebSocketPriceCache._force_close_socket,
"ram_keep_codes", "LS 장양보 → UNREG 후 RAM trim(영구·보유 pin)",
)
_소스필수(
실패목록, LSWebSocketPriceCache._send_raw,
"_force_close_socket", "LS send 실패/락타임아웃 → UNREG 후 close",
)
_소스필수(
실패목록, KiwoomWebSocketPriceCache.stop,
"_unsubscribe_all_before_close", "키움 stop → REMOVE",
)
_소스필수(
실패목록, WSManager.stop,
"kis_ws_ob.stop", "WSManager.stop → 호가전용 소켓도 stop",
)
_파일필수(
실패목록,
"kis_trader/network/kiwoom_condition_manager.py",
"CNSRCLR",
"키움 조건검색 stop → CNSRCLR",
)
except Exception as exc:
실패목록.append(f"구독해제 검증 오류: {exc}")
_실패(f"구독해제 검증 오류: {exc}")
import traceback
traceback.print_exc()
# ── 13) CRITICAL 잠금 (freeze / 호가TTL / OHLC / HTS skip / approval) ─
print("\n[13단계] CRITICAL 잠금 · 호가 TTL 분리 · Optuna OHLC 폴백 OFF")
try:
from kis_trader.engine.orderbook_filter import _fetch_snapshot
from kis_trader.ws.kis_ws import CandleAggregator
freeze_on = get_env_bool("WS_CANDLE_FREEZE_ON_CONFIRM", True)
print(
f" CANDLE_FREEZE={'ON' if freeze_on else 'OFF'} "
f"| DB {_db원문('WS_CANDLE_FREEZE_ON_CONFIRM')} "
f"(코드기본=True, OFF는 사용자 명시 승인 전 금지)"
)
if not freeze_on:
실패목록.append("WS_CANDLE_FREEZE_ON_CONFIRM=OFF")
_실패("봉 freeze OFF — 사용자 승인 없이 금지")
else:
_성공("봉 freeze ON (확정봉 OHLCV 유지)")
_파일금지(
실패목록,
"kis_trader/ws/kis_ws.py",
'get_env_bool("WS_CANDLE_FREEZE_ON_CONFIRM", False)',
"kis_ws freeze 코드기본 True",
)
_소스필수(
실패목록, CandleAggregator.merge_confirmed_bars,
"WS_CANDLE_FREEZE_ON_CONFIRM",
"갭보정 병합 경로에 freeze 키 존재",
)
_소스필수(
실패목록, _fetch_snapshot,
"WS_ORDERBOOK_FILTER_MAX_AGE_SEC",
"호가필터 판정나이 = FILTER_MAX_AGE",
)
_소스필수(
실패목록, _fetch_snapshot,
"WS_ORDERBOOK_TICK_MAX_AGE_SEC",
"호가 저장TTL = TICK_MAX_AGE (판정과 분리)",
)
print(
f" 호가나이 DB: FILTER_MAX_AGE={_db원문('WS_ORDERBOOK_FILTER_MAX_AGE_SEC')} "
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",
'base_params["backtest_tick_fallback_ohlc"] = False',
"Optuna OHLC 폴백 강제 OFF",
)
for sid in 전략목록:
= f"{sid}_SKIP_HTS_SCAN_DUPES"
if not in ENV_CONFIG_KEYS:
실패목록.append(f"ENV 누락 {}")
_실패(f"ENV_CONFIG_KEYS 에 {} 없음")
else:
on = get_env_bool(, False)
print(f" {_전략표시(sid)} {}={'ON' if on else 'OFF'} | DB {_db원문()}")
if on:
_주의(f"{}=true — 운영 기본은 false. 사용자 지시 없이 바꾸지 말 것")
else:
_성공(f"{}=false (운영 기본 유지)")
_파일필수(
실패목록,
"kis_trader/strategies/base.py",
"TICK_SELL_ASYNC_PLACE",
"틱매도 place 비동기(수신스레드 REST 금지)",
)
_파일필수(
실패목록,
"kis_trader/strategies/base.py",
"_tick_sell_worker_loop",
"틱매도 워커",
)
_파일필수(
실패목록,
"kis_trader/strategies/base.py",
"_sell_inflight",
"틱·루프 종목당 매도 1장",
)
if "TICK_SELL_ASYNC_PLACE" not in ENV_CONFIG_KEYS:
실패목록.append("ENV 누락 TICK_SELL_ASYNC_PLACE")
_실패("ENV_CONFIG_KEYS 에 TICK_SELL_ASYNC_PLACE 없음")
else:
_성공("TICK_SELL_ASYNC_PLACE env 등록")
_소스필수(
실패목록, KISWebSocketPriceCache._get_approval_key,
"KISApprovalManager.instance",
"한투 approval_key 공유 캐시",
)
except Exception as exc:
실패목록.append(f"CRITICAL 잠금 검증 오류: {exc}")
_실패(f"CRITICAL 잠금 검증 오류: {exc}")
import traceback
traceback.print_exc()
# ── 최종 ───────────────────────────────────────────────────────
print("\n" + "=" * 78)
if 실패목록:
print(f"최종: 실패 {len(실패목록)}")
for x in 실패목록:
print(f" - {x}")
print("=" * 78)
return False
print("최종: 통과 (위 DB ON/OFF 표기가 실매 기준과 일치하는지 함께 확인)")
print("👑 [최종 판정] 완결!")
print("=" * 78)
return True
class _Tee:
def __init__(self, *files: Any) -> None:
self.files = files
def write(self, s: str) -> None:
for f in self.files:
f.write(s)
f.flush()
def flush(self) -> None:
for f in self.files:
f.flush()
def _setup_run_log() -> tuple:
log_dir = ROOT / "logs"
log_dir.mkdir(exist_ok=True)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
path = log_dir / f"test_live_execution_validation_{ts}.log"
fp = path.open("w", encoding="utf-8")
sys.stdout = _Tee(sys.__stdout__, fp) # type: ignore[assignment]
sys.stderr = _Tee(sys.__stderr__, fp) # type: ignore[assignment]
print(f"로그 파일: {path}")
return path, fp
if __name__ == "__main__":
log_path, log_fp = _setup_run_log()
code = 1
try:
ok = run_validation()
print(f"로그 파일: {log_path}")
code = 0 if ok else 1
finally:
sys.stdout = sys.__stdout__
sys.stderr = sys.__stderr__
try:
log_fp.close()
except Exception:
pass
sys.exit(code)