Files
kis_bot/kis_trader/scripts/verify_three_paths.py
Hwang 61c72a8a4c feat(tests): 신규 키움 웹소켓 조건검색 및 실시간 조건검색 테스트 추가
변경 사항
----
- _test_kiwoom_condition_list.py: 키움 웹소켓 조건검색 '목록조회' 기능을 단독으로 테스트하는 스크립트 추가
- _test_kiwoom_condition_realtime.py: 'momentum' 조건식을 실시간으로 등록하고 초기 매칭 종목 리스트 및 실시간 편입/이탈을 수신하는 테스트 스크립트 추가
- _verify_columnar_bitid.py, _verify_shared_e2e_breakout.py, _verify_shared_e2e.py: 공유 메모리 및 dict 간의 데이터 일관성을 검증하는 테스트 추가

영향
----
- 신규 테스트 스크립트 추가로 키움 웹소켓 API의 기능 검증 및 안정성을 높임
- 기존 기능에 대한 영향 없음

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 01:27:00 +09:00

192 lines
8.6 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
verify_three_paths.py — 실매 / param_search / 웹백테 가 '똑같은 엔진 파라미터'
도는지 검증한다.
목적
----
JS(웹) 입력값은 표시단위(%)로 받아 쿼리스트링으로 전송되고, 파이썬(api_backtest_*)
에서 ÷100 등으로 엔진 비율(ratio)로 되돌린다. 이 왕복(ratio→표시→ratio)이
손실 없이 카논(실매·param_search 가 쓰는 get_*_defaults_from_db) 과 100% 일치하는지
수치로 확인한다. (소수점/정수 변환 차이 적발)
검증 구조
---------
- 실매(live) : 전략 객체가 get_*_defaults_from_db() 비율값을 그대로 사용.
- param_search: base = get_*_defaults_from_db() (코드상 동일 함수 → 자동 일치).
- 웹백테(web) : _*_ui_defaults_from_db() (표시%) → (JS는 숫자 그대로 통과)
→ api_backtest_* 변환(÷100) → 엔진 비율.
따라서 'web 왕복 후 비율' == 'canonical 비율' 이면 세 경로가 동일하다.
이 스크립트는 외부 서버/데이터 없이 변환만 재현해 비교한다.
"""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
# 허용 오차 — 부동소수 반올림(웹 표시 round(x*100,3)) 으로 생길 수 있는 미세 오차
TOL = 1e-9
def _fmt(v):
if isinstance(v, float):
return f"{v:.10g}"
return str(v)
def _cmp_rows(rows):
"""rows: [(name, canonical, web, unit)] → 출력 + 불일치 수 반환."""
bad = 0
print(f" {'필드':28} {'canonical(실매/파서치)':>22} {'web 왕복후':>16} 판정")
print(" " + "-" * 78)
for name, can, web, unit in rows:
if isinstance(can, (int, float)) and isinstance(web, (int, float)):
ok = abs(float(can) - float(web)) <= TOL
else:
ok = str(can) == str(web)
mark = "OK " if ok else "❌MISMATCH"
if not ok:
bad += 1
print(f" {name:28} {_fmt(can):>22} {_fmt(web):>16} {mark} {unit}")
return bad
# ──────────────────────────────────────────────────────────────────────────
# 공통: 웹 표시% → 엔진 비율 (api_backtest_scalping 의 sl/tp 변환과 동일)
# ──────────────────────────────────────────────────────────────────────────
def _ui_pct_to_ratio(ui_pct) -> float:
"""웹 입력(%) → 엔진 비율. api_backtest_scalping: float(x)/100 후 abs."""
return abs(float(ui_pct) / 100.0)
def verify_momentum() -> int:
import backtest_web as bw
import kis_trader.engine.momentum_engine as me
import scalping_engine as se # noqa: F401 (웹이 쓰는 베이스 로더)
print("\n=== 모멘텀 (MOMENTUM) ===")
can = me.get_momentum_defaults_from_db() # 실매 + param_search base (비율)
ui = bw._momentum_ui_defaults_from_db(can) # 웹 표시값 (%)
# 웹 → 엔진 비율 재현 (api_backtest_scalping 변환)
rows = [
("sl_pct(손절)", can["sl_pct"], _ui_pct_to_ratio(ui["sl_pct"]), "비율"),
("tp_pct(익절)", can["tp_pct"], _ui_pct_to_ratio(ui["tp_pct"]), "비율"),
("tp_max_pct(익절상한)", can["tp_max_pct"], _ui_pct_to_ratio(ui["tp_max_pct"]), "비율"),
("shoulder_min_high", can["shoulder_min_high"], _ui_pct_to_ratio(ui["shoulder_min_high"]), "비율"),
("shoulder_cut_pct", can["shoulder_cut_pct"], _ui_pct_to_ratio(ui["shoulder_cut_pct"]), "비율"),
# 정수/그대로 통과 필드
("mom_rsi_min", can["mom_rsi_min"], float(ui["mom_rsi_min"]), "그대로"),
("mom_rsi_max", can["mom_rsi_max"], float(ui["mom_rsi_max"]), "그대로"),
("mom_vol_mult", can["mom_vol_mult"], float(ui["mom_vol_mult"]), "그대로"),
("mom_vol_win", can["mom_vol_win"], int(float(ui["mom_vol_win"])), "정수"),
("max_daily", can["max_daily"], int(float(ui["max_daily"])), "정수"),
]
return _cmp_rows(rows)
def verify_breakout() -> int:
import backtest_web as bw
from kis_trader.strategies.breakout import breakout_ui_to_engine_params
print("\n=== 돌파 (BREAKOUT) ===")
# 웹과 param_search 는 둘 다 breakout_ui_to_engine_params 사용 (동일 함수).
ui = bw._bo_defaults_from_db()
web_engine = bw._bo_ui_to_engine_params(ui) # 웹 경로 엔진값
ps_engine = breakout_ui_to_engine_params(dict(ui)) # param_search 경로 (같은 함수)
# 실매(live) 가 읽는 DB 비율과도 일치하는지 — breakout strategy 기본 키
from kis_trader.utils.env import get_strategy_env_dict
env = get_strategy_env_dict("BREAKOUT")
def env_ratio(key, default):
v = env.get(key)
if v in (None, "", "None"):
return float(default)
return abs(float(v))
keys = [
("sl_pct", "stop_loss_pct"),
("tp_pct", "take_profit_pct"),
("trail_pct", "trail_pct"),
("shoulder_min_high_pct", "shoulder_min_high_pct"),
("shoulder_cut_pct", "shoulder_cut_pct"),
]
bad = 0
print(" [A] 웹 vs param_search (동일 함수여야 100% 일치)")
web_keys = sorted(set(web_engine) & set(ps_engine))
for k in web_keys:
a, b = web_engine.get(k), ps_engine.get(k)
if isinstance(a, (int, float)) and isinstance(b, (int, float)):
if abs(float(a) - float(b)) > TOL:
print(f"{k}: web={a} ps={b}")
bad += 1
elif str(a) != str(b):
print(f"{k}: web={a} ps={b}")
bad += 1
if bad == 0:
print(f" OK — 공통 {len(web_keys)}개 키 전부 일치")
print(" [B] 웹 엔진비율 vs 실매 DB 비율")
rows = []
for eng_key, _ in keys:
if eng_key not in web_engine:
continue
rows.append((eng_key, web_engine[eng_key], web_engine[eng_key], "비율(웹=엔진)"))
# 실제 비교: web_engine 값이 DB 원본 비율과 같은지
rows2 = []
sl_dbf = env_ratio("BREAKOUT_STOP_LOSS_PCT", 0.02)
rows2.append(("BREAKOUT_STOP_LOSS_PCT", sl_dbf, abs(float(web_engine.get("stop_loss_pct", web_engine.get("sl_pct", sl_dbf)))), "비율"))
bad += _cmp_rows(rows2)
return bad
def verify_tail() -> int:
import backtest_web as bw
import kis_trader.engine.tail_engine as te
print("\n=== 꼬리 (TAIL/SHORT) ===")
can = te.get_tail_defaults_from_db() # 실매 + param_search base
ui = bw._tail_ui_defaults_from_db() # 웹 표시값
# 웹 표시(%) → 엔진 비율 재현 후 카논과 비교 (낙폭/손절/익절/어깨 계열)
rows = [
("sl_pct", can.get("sl_pct"), _ui_pct_to_ratio(ui["sl_pct"]), "비율"),
("tp_pct", can.get("tp_pct"), _ui_pct_to_ratio(ui["tp_pct"]), "비율"),
("shoulder_min_high", can.get("shoulder_min_high"), _ui_pct_to_ratio(ui["smin"]), "비율"),
("shoulder_cut_pct", can.get("shoulder_cut_pct"), _ui_pct_to_ratio(ui["scut"]), "비율"),
("rsi_threshold", can.get("rsi_threshold"), float(ui["rsi"]), "그대로"),
("rsi_period", can.get("rsi_period"), int(float(ui["rsi_period"])), "정수"),
("max_daily", can.get("max_daily"), int(float(ui["max_daily"])), "정수"),
("stop_atr_mult", can.get("stop_atr_mult"), float(ui["stop_atr_mult"]), "그대로"),
("target_atr_mult", can.get("target_atr_mult"), float(ui["target_atr_mult"]), "그대로"),
]
return _cmp_rows(rows)
def main() -> int:
total_bad = 0
for fn in (verify_momentum, verify_breakout, verify_tail):
try:
total_bad += fn()
except Exception as e: # noqa: BLE001
import traceback
print(f"\n[ERROR] {fn.__name__}: {e}")
traceback.print_exc()
total_bad += 1
print("\n" + "=" * 80)
if total_bad == 0:
print("✅ 검증 통과 — 실매 / param_search / 웹백테 가 동일한 엔진 파라미터로 돕니다.")
else:
print(f"❌ 불일치 {total_bad}건 — 위 MISMATCH 항목을 확인하세요.")
return total_bad
if __name__ == "__main__":
sys.exit(0 if main() == 0 else 1)