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>
This commit is contained in:
2026-07-06 01:27:00 +09:00
parent d8ba01afa4
commit 61c72a8a4c
171 changed files with 176914 additions and 7329 deletions

View File

@@ -0,0 +1,233 @@
#!/usr/bin/env python3
"""
모멘텀 백테 — baseline vs 스캔 eval 메모이즈 동일성·속도 비교 (프로덕션 코드 수정 없음).
제안 최적화: 틱·호가 없을 때 eval_momentum_buy_at_index 를
(code, signal_idx, daily_cnt, last_exit_dt) 키로 캐시 → 10초 스캔큐 내 중복 연산 제거.
유니버스·포트폴리오·청산 루프는 그대로 유지 → 결과 불변 전제 검증용.
사용:
cd /home/hoon/kis_bot
python3 kis_trader/scripts/compare_momentum_scan_memo.py
python3 kis_trader/scripts/compare_momentum_scan_memo.py --start 2026-06-22 --end 2026-06-26
"""
from __future__ import annotations
import argparse
import copy
import json
import sys
import time
from typing import Any, Dict, List, Optional, Tuple
sys.path.insert(0, "/home/hoon/kis_bot")
from kis_trader.backtest import momentum_backtest_common as mbc
from kis_trader.backtest import momentum_portfolio_backtest as mpb
from kis_trader.backtest.momentum_backtest_common import resolve_momentum_universe
from kis_trader.backtest.param_search_momentum import _load_candles_for_search, _ui_to_engine_params
_orig_eval = mpb.eval_momentum_buy_at_index
_eval_cache: Dict[Tuple, Tuple] = {}
_cache_hits = 0
_cache_miss = 0
def _state_key(state: Dict[str, Any]) -> Tuple:
led = state.get("last_exit_dt")
led_s = led.isoformat() if led is not None else ""
return (int(state.get("daily_cnt", 0) or 0), led_s)
def _params_eval_key(params: Dict[str, Any]) -> Tuple:
"""매수신호에 영향 주는 엔진 파라미터 + indicator cache 객체 id."""
ic = params.get("_indicator_cache")
keys = (
"rsi_period", "mom_rsi_min", "mom_rsi_max", "time_start_hm", "mom_time_end_hm",
"time_end_hm", "cooldown_min", "max_daily", "max_daily_chg", "min_price",
"use_defense_filters", "use_high_chase_filter", "use_daily_range_filter",
"use_ema_filter", "use_rsi_max_filter", "ema_fast_period", "ema_slow_period",
"high_chase_thr", "mom_vol_mult", "mom_vol_win", "pattern_breakout",
"pattern_pullback", "chase_lookback_min", "pullback_lookback_min",
"pullback_min_pct", "pullback_max_pct", "mom_max_from_open_pct",
"mom_min_from_open_pct", "skip_hts_scan_dupes",
"_ob_max_spread_pct", "_ob_min_bid_ask_ratio", "_ob_ask_max_mult",
"_backtest_orderbook_snapshot", "_backtest_program_snapshot",
"_backtest_log_orderbook_verdict",
)
parts: List[Any] = [id(ic)]
for k in keys:
v = params.get(k)
if isinstance(v, dict):
parts.append(id(v))
elif isinstance(v, (list, tuple)):
parts.append(tuple(v) if len(v) < 8 else id(v))
else:
parts.append(v)
return tuple(parts)
def _memo_eval(
candles: List[Dict],
i: int,
params: Dict[str, Any],
state: Dict[str, Any],
) -> Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]]:
global _cache_hits, _cache_miss
key = (id(candles), int(i), _state_key(state), _params_eval_key(params))
if key in _eval_cache:
_cache_hits += 1
return _eval_cache[key]
r = _orig_eval(candles, i, params, state)
_eval_cache[key] = r
_cache_miss += 1
return r
def _install_memo(enabled: bool) -> None:
global _cache_hits, _cache_miss
_cache_hits = 0
_cache_miss = 0
_eval_cache.clear()
if enabled:
mpb.eval_momentum_buy_at_index = _memo_eval
else:
mpb.eval_momentum_buy_at_index = _orig_eval
def _trade_key(t: Dict[str, Any]) -> Tuple:
return (
str(t.get("code") or ""),
str(t.get("buy_time") or t.get("entry_time") or ""),
str(t.get("sell_time") or t.get("exit_time") or ""),
)
def _norm_trade(t: Dict[str, Any]) -> Dict[str, Any]:
return {
"code": str(t.get("code") or ""),
"buy_time": str(t.get("buy_time") or t.get("entry_time") or ""),
"sell_time": str(t.get("sell_time") or t.get("exit_time") or ""),
"buy_price": round(float(t.get("buy_price") or t.get("entry") or 0), 4),
"sell_price": round(float(t.get("sell_price") or t.get("exit") or 0), 4),
"qty": int(t.get("qty") or 0),
"pnl": int(round(float(t.get("pnl") or 0))),
"sell_reason": str(t.get("sell_reason") or ""),
"hold_min": round(float(t.get("hold_min") or 0), 1),
}
def _run_once(
cc: Dict,
eng: Dict,
univ: Optional[Dict],
meta: Dict,
*,
use_memo: bool,
) -> Tuple[List[Dict], Dict[str, Any], float, Dict[str, int]]:
_install_memo(use_memo)
t0 = time.time()
tr = mbc.run_momentum_backtest_web_aligned(
cc, copy.deepcopy(eng), univ,
slot_money=300000, fee_rate=0.00015, sell_tax=0.0018,
max_stocks=20, total_budget_krw=6000000,
meta_out=copy.deepcopy(meta),
)
elapsed = time.time() - t0
stats = mbc.summarize_momentum_trades(
tr, total_budget_krw=6000000, period_days=5,
)
cache_info = {"hits": _cache_hits, "misses": _cache_miss}
return tr, stats, elapsed, cache_info
def _compare_trades(base: List[Dict], opt: List[Dict]) -> Dict[str, Any]:
bn = [_norm_trade(t) for t in sorted(base, key=_trade_key)]
on = [_norm_trade(t) for t in sorted(opt, key=_trade_key)]
out: Dict[str, Any] = {
"baseline_count": len(bn),
"memo_count": len(on),
"identical": bn == on,
"diff_samples": [],
}
if bn == on:
return out
n = max(len(bn), len(on))
for i in range(n):
b = bn[i] if i < len(bn) else None
o = on[i] if i < len(on) else None
if b != o:
out["diff_samples"].append({"idx": i, "baseline": b, "memo": o})
if len(out["diff_samples"]) >= 10:
break
return out
def _load_params_from_json() -> Dict[str, Any]:
path = "kis_trader/backtest/results/search_momentum_fast_20260627_015211.json"
with open(path, encoding="utf-8") as f:
return json.load(f)["top"][0]["merged_params"]
def main() -> int:
parser = argparse.ArgumentParser(description="모멘텀 baseline vs scan-eval 메모이즈 비교")
parser.add_argument("--start", default="2026-06-22")
parser.add_argument("--end", default="2026-06-26")
args = parser.parse_args()
sk = args.start.replace("-", "") + "0000"
ek = args.end.replace("-", "") + "2359"
print(f"기간: {args.start} ~ {args.end}")
print("=" * 70)
t_load = time.time()
cc = _load_candles_for_search(args.start, args.end, 3)
univ, src, n_slots, _, _ = resolve_momentum_universe(
sk[:8], ek[:8], use_saved_history=True,
)
mp = _load_params_from_json()
eng = _ui_to_engine_params(mp)
eng.update({
"slot_money": 300000,
"max_stocks": 20,
"total_budget_krw": 6000000,
"portfolio_mode": True,
})
meta = {"start_key": sk, "end_key": ek}
print(f"로드 {time.time() - t_load:.1f}s | 종목 {len(cc)} | 유니버스 {src} {n_slots}슬롯")
print("파라미터: search_momentum_fast 1위 merged (EMA OFF, vol×3, sl1.5%)")
print("=" * 70)
print("\n[1/2] BASELINE (현재 코드 그대로)")
tr_b, st_b, el_b, _ = _run_once(cc, eng, univ, meta, use_memo=False)
print(f" 시간 {el_b:.1f}s | 거래 {st_b['total_trades']} | 손익 {int(st_b['total_pnl']):+,} | "
f"PF {st_b['pf']} | 승률 {st_b['win_rate']}%")
print("\n[2/2] MEMO (eval_momentum_buy_at_index 메모이즈 — 프로덕션 미적용, 스크립트만)")
tr_m, st_m, el_m, cache = _run_once(cc, eng, univ, meta, use_memo=True)
print(f" 시간 {el_m:.1f}s | 거래 {st_m['total_trades']} | 손익 {int(st_m['total_pnl']):+,} | "
f"PF {st_m['pf']} | 승률 {st_m['win_rate']}%")
print(f" eval 캐시 hit {cache['hits']:,} / miss {cache['misses']:,} "
f"(hit률 {100.0 * cache['hits'] / max(1, cache['hits'] + cache['misses']):.1f}%)")
cmp = _compare_trades(tr_b, tr_m)
speedup = el_b / el_m if el_m > 0 else 0.0
print("\n" + "=" * 70)
print("비교 결과")
print(f" 거래건수: baseline {cmp['baseline_count']} vs memo {cmp['memo_count']}")
print(f" 손익: baseline {int(st_b['total_pnl']):+,} vs memo {int(st_m['total_pnl']):+,}")
print(f" PF: baseline {st_b['pf']} vs memo {st_m['pf']}")
print(f" 속도: baseline {el_b:.1f}s → memo {el_m:.1f}s ({speedup:.2f}x)")
print(f" 거래내역 동일: {'✅ YES' if cmp['identical'] else '❌ NO'}")
if not cmp["identical"]:
print("\n ⚠️ 차이 샘플 (최대 10건):")
for d in cmp["diff_samples"]:
print(f" #{d['idx']}")
print(f" baseline: {d['baseline']}")
print(f" memo: {d['memo']}")
print("=" * 70)
return 0 if cmp["identical"] else 1
if __name__ == "__main__":
raise SystemExit(main())