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,297 @@
#!/usr/bin/env python3
"""
kis_trader/backtest/param_search_range_break.py — 박스권 돌파 파라미터 Grid Search
====================================================================================
실행:
cd /home/hoon/kis_bot
python3 -m kis_trader.backtest.param_search_range_break --start 2026-06-01 --end 2026-06-16 --mode fast
"""
from __future__ import annotations
import argparse
import heapq
import json
import logging
import os
import sys
import time
from datetime import datetime, timedelta
from itertools import product
from typing import Any, Dict, List, Optional, Tuple
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(os.path.dirname(HERE))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
logging.getLogger("TradeDB").setLevel(logging.WARNING)
from database import TradeDB
from kis_trader.backtest import range_break_backtest_common as rbc
from kis_trader.backtest.backtest_portfolio_common import (
portfolio_env_patch,
session_env_patch,
)
from kis_trader.backtest.param_search_cli_common import (
add_portfolio_cli_args,
add_search_filter_cli_args,
apply_session_to_fixed,
combo_passes_search_filters,
format_session_hm,
search_json_meta,
)
from kis_trader.backtest.param_search_pool import cap_combos_uniform
from kis_trader.engine.range_break_engine import range_break_ui_to_engine_params
from kis_trader.strategies.breakout import (
breakout_invest_amount_krw,
normalize_breakout_max_loss_krw,
)
from kis_trader.utils.env import get_env_from_db
def _latest_json(prefix: str) -> Optional[str]:
out_dir = os.path.join(ROOT, "kis_trader", "backtest", "results")
if not os.path.isdir(out_dir):
return None
files = [f for f in os.listdir(out_dir) if f.startswith(prefix) and f.endswith(".json")]
if not files:
return None
files.sort(key=lambda x: os.path.getmtime(os.path.join(out_dir, x)), reverse=True)
return os.path.join(out_dir, files[0])
def _grid(mode: str) -> Dict[str, List[Any]]:
if mode == "coarse":
return {
"box_lookback_min": [20, 30, 45],
"box_max_width_pct": [2.0, 2.5, 3.5],
"setup_vol_max_mult": [0.6, 0.8, 1.0],
"vol_mult": [1.5, 2.0, 3.0],
"vol_window": [5, 7, 10],
"sl_pct": [2.0, 3.0],
"tp_pct": [8.0, 10.0, 12.0],
"trail_pct": [1.0, 1.5],
"time_end_hm": [1500, 1520],
}
if mode == "fine":
return {
"box_lookback_min": [25, 30, 35],
"box_max_width_pct": [2.0, 2.5, 3.0],
"setup_vol_max_mult": [0.7, 0.8, 0.9],
"vol_mult": [1.8, 2.0, 2.5],
"vol_window": [7],
"sl_pct": [2.5, 3.0],
"tp_pct": [9.0, 10.0],
"trail_pct": [1.2, 1.5],
"time_end_hm": [1520],
}
# fast (default)
return {
"box_lookback_min": [20, 30, 40],
"box_max_width_pct": [2.0, 2.5, 3.0],
"setup_vol_max_mult": [0.7, 0.8],
"vol_mult": [1.5, 2.0, 2.5],
"vol_window": [7],
"sl_pct": [2.5, 3.0],
"tp_pct": [8.0, 10.0],
"trail_pct": [1.5],
"time_end_hm": [1520],
}
def _score(stats: Dict[str, Any], min_trades: int) -> float:
total = int(stats.get("total_trades") or 0)
if total < min_trades:
return -1e18
pnl = float(stats.get("total_pnl") or 0)
wr = float(stats.get("win_rate") or 0)
# 손익 우선, 승률 보조
return pnl + wr * 100.0
def _run_one(
candles_by_code: Dict[str, List[Dict]],
universe_by_slot: Optional[Dict[str, List[str]]],
ui: Dict[str, Any],
fee_rate: float,
sell_tax: float,
portfolio: Dict[str, Any],
) -> Tuple[float, Dict[str, Any]]:
engine = range_break_ui_to_engine_params(ui)
engine.update(portfolio)
trades = rbc.run_range_break_backtest_web_aligned(
candles_by_code,
engine,
universe_by_slot=universe_by_slot,
slot_money=float(portfolio["slot_money"]),
fee_rate=fee_rate,
sell_tax=sell_tax,
max_stocks=int(portfolio["max_stocks"]),
total_budget_krw=float(portfolio["total_budget_krw"]),
)
stats = rbc.summarize_range_break_trades(
trades,
total_budget_krw=float(portfolio["total_budget_krw"]),
period_days=max(1, int(ui.get("_period_days") or 1)),
)
sc = _score(stats, int(ui.get("_min_trades") or 1))
return sc, {
"params": dict(ui),
"stats": stats,
"merged_params": engine,
}
def main() -> int:
parser = argparse.ArgumentParser(description="박스권 돌파 백테스트 파라미터 Grid Search")
parser.add_argument("--start", default=(datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d"))
parser.add_argument("--end", default=datetime.now().strftime("%Y-%m-%d"))
parser.add_argument("--mode", default="fast", choices=["fast", "coarse", "fine"])
parser.add_argument(
"--max-combos", type=int, default=None, dest="max_combos",
help="백테 조합 상한 (fast 기본 env RANGE_BREAK_FAST_MAX_COMBOS 또는 PARAM_SEARCH_FAST_MAX_COMBOS=200, 0=무제한)",
)
parser.add_argument("--top", type=int, default=50)
parser.add_argument("--min-trades", type=int, default=1)
add_search_filter_cli_args(parser)
parser.add_argument("--fallback-universe", action="store_true")
add_portfolio_cli_args(parser)
args = parser.parse_args()
start_key, end_key, start_ymd, end_ymd = rbc.date_keys(args.start, args.end)
period_days = max(1, (datetime.strptime(args.end, "%Y-%m-%d") - datetime.strptime(args.start, "%Y-%m-%d")).days + 1)
db = TradeDB()
try:
env_row = dict((db.get_latest_env() or {}).get("snapshot") or {})
fee_rate, sell_tax, slot_from_env = rbc.fee_and_slot_from_env(env_row)
portfolio = rbc.resolve_range_break_portfolio_params(
env_row,
None,
slot_money=args.slot_money or slot_from_env,
max_stocks=args.max_stocks,
total_budget_krw=args.total_budget,
)
candles_by_code, _ = rbc.load_range_break_candles_by_code(db, start_key, end_key, {})
if not candles_by_code:
print("❌ 캔들 데이터 없음")
return 1
use_hist = not args.fallback_universe
universe_by_slot, univ_src, _, _ = rbc.resolve_range_break_universe(
start_ymd, end_ymd, use_saved_history=use_hist,
)
print(f"📊 종목 {len(candles_by_code)} · 유니버스={univ_src}")
fixed: Dict[str, Any] = {
"time_start_hm": int(get_env_from_db("RANGE_BREAK_TIME_START", 1030) or 1030),
"setup_bear_bars_min": int(get_env_from_db("RANGE_BREAK_SETUP_BEAR_BARS_MIN", 1) or 1),
"box_min_width_pct": float(get_env_from_db("RANGE_BREAK_BOX_MIN_WIDTH_PCT", 0.3) or 0.3),
"max_daily": int(get_env_from_db("RANGE_BREAK_MAX_DAILY", 1) or 1),
"cooldown_min": float(get_env_from_db("RANGE_BREAK_COOLDOWN_SEC", 1800) or 1800) / 60.0,
"max_daily_chg": float(get_env_from_db("RANGE_BREAK_MAX_DAILY_CHG", 25) or 25),
"min_price": float(get_env_from_db("RANGE_BREAK_MIN_PRICE", 1000) or 1000),
"max_loss_krw": normalize_breakout_max_loss_krw(
get_env_from_db("RANGE_BREAK_MAX_LOSS_PER_TRADE_KRW", 200_000),
),
"slot_money": int(portfolio["slot_money"]),
"use_high_chase_filter": True,
"high_chase_thr": float(get_env_from_db("RANGE_BREAK_HIGH_CHASE_THR", 0.96) or 0.96),
"_period_days": period_days,
"_min_trades": args.min_trades,
}
apply_session_to_fixed(
fixed,
time_start_hm=args.time_start,
time_end_hm=args.time_end,
)
sl_ui = float(fixed.get("sl_pct") or 3.0)
fixed["slot_money"] = int(
breakout_invest_amount_krw(fixed["max_loss_krw"], sl_ui, float(portfolio["slot_money"]))
)
grid = _grid(args.mode)
keys = list(grid.keys())
combos = list(product(*(grid[k] for k in keys)))
dict_combos = [dict(zip(keys, vals)) for vals in combos]
dict_combos, total_grid, max_combos_cap, dropped_by_cap = cap_combos_uniform(
dict_combos,
args.mode,
strategy_env_prefix="RANGE_BREAK",
default_fast=200,
max_combos_override=args.max_combos,
)
print(
f"🔍 {args.mode} 그리드 {total_grid:,} → 백테 {len(dict_combos):,} · "
f"{format_session_hm(fixed)}"
)
if args.mode == "fast":
print(f"📌 [fast] {total_grid:,}{max_combos_cap}균등샘플")
if dropped_by_cap:
print(f" (max-combos={max_combos_cap} 균등 샘플, 제외 {dropped_by_cap:,}개)")
heap: List[Tuple[float, int, Dict[str, Any]]] = []
t0 = time.time()
for i, combo in enumerate(dict_combos, 1):
ui = dict(fixed)
ui.update(combo)
sc, item = _run_one(
candles_by_code, universe_by_slot, ui,
fee_rate, sell_tax, portfolio,
)
wr = float(item["stats"].get("win_rate") or 0)
pf = float(item["stats"].get("pf") or 0)
if sc > -1e17 and combo_passes_search_filters(
win_rate=wr, pf=pf,
min_win_rate=args.min_win_rate, min_pf=args.min_pf,
):
heapq.heappush(heap, (sc, i, item))
if len(heap) > args.top:
heapq.heappop(heap)
if i % 50 == 0 or i == len(dict_combos):
print(f"{i}/{len(dict_combos)} ({time.time()-t0:.1f}s)")
top = [heapq.heappop(heap) for _ in range(len(heap))]
top.reverse()
out_dir = os.path.join(ROOT, "kis_trader", "backtest", "results")
os.makedirs(out_dir, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
out_path = os.path.join(out_dir, f"search_range_break_{ts}.json")
payload = {
**search_json_meta(portfolio, fixed),
"strategy": "RANGE_BREAK",
"start": args.start,
"end": args.end,
"mode": args.mode,
"grid_combos": total_grid,
"max_combos_cap": max_combos_cap,
"universe": univ_src,
"slot_money": portfolio["slot_money"],
"max_stocks": portfolio["max_stocks"],
"total_budget_krw": portfolio["total_budget_krw"],
"top": [t[2] for t in top],
}
with open(out_path, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
print(f"✅ 저장: {out_path}")
for rank, (sc, _, item) in enumerate(top[:10], 1):
st = item["stats"]
p = item["params"]
print(
f"#{rank} PnL={int(st.get('total_pnl',0)):,} "
f"WR={st.get('win_rate',0):.1f}% "
f"N={st.get('total_trades',0)} "
f"box={p.get('box_lookback_min')} "
f"w={p.get('box_max_width_pct')} "
f"volX={p.get('vol_mult')}"
)
return 0
finally:
db.close()
if __name__ == "__main__":
raise SystemExit(main())