전략별 tick/scan 매도 락 대신 계좌 단일 PriorityQueue로 place를 B-full 직렬화한다. 틱매도 only_code 필터와 inflight 중복 enqueue 방지로 REST 폭주를 줄인다. Co-authored-by: Cursor <cursoragent@cursor.com>
1321 lines
59 KiB
Python
Executable File
1321 lines
59 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_locked)
|
|
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_locked)
|
|
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/execution/order_worker.py",
|
|
"AccountOrderWorker",
|
|
"계좌 단일 주문 큐 Worker",
|
|
)
|
|
_파일필수(
|
|
실패목록,
|
|
"kis_trader/strategies/base.py",
|
|
"_enqueue_sell",
|
|
"매도 signal → OrderWorker",
|
|
)
|
|
_파일필수(
|
|
실패목록,
|
|
"kis_trader/strategies/base.py",
|
|
"_enqueue_buy",
|
|
"매수 signal → OrderWorker",
|
|
)
|
|
_파일필수(
|
|
실패목록,
|
|
"kis_trader/strategies/base.py",
|
|
"_sell_inflight",
|
|
"틱·루프 종목당 매도 intent 1장",
|
|
)
|
|
if "ORDER_WORKER_MAX_QUEUE" not in ENV_CONFIG_KEYS:
|
|
실패목록.append("ENV 누락 ORDER_WORKER_MAX_QUEUE")
|
|
_실패("ENV_CONFIG_KEYS 에 ORDER_WORKER_MAX_QUEUE 없음")
|
|
else:
|
|
_성공("ORDER_WORKER_MAX_QUEUE env 등록")
|
|
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 등록 (레거시·틱 place는 OrderWorker 경유)")
|
|
|
|
_소스필수(
|
|
실패목록, 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)
|