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:
297
kis_trader/backtest/param_search_range_break.py
Normal file
297
kis_trader/backtest/param_search_range_break.py
Normal 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())
|
||||
Reference in New Issue
Block a user