Files
kis_bot/kis_trader/backtest/param_search_range_break.py
Your Name fc27e726f9 feat: 새로운 안전 규칙 및 최적화 적용을 통한 트레이딩 시스템 개선
변경 사항 (Changes):

구문 오류(Syntax error) 및 토큰 낭비를 방지하기 위해 에이전트 쉘(Agent shell)과 파이썬 코드 스니펫에 다수의 신규 안전 규칙(Safety rules)을 추가함.

스키마 검증 및 적절한 SQL 포맷팅을 보장하기 위해 임시(Ad-hoc) 데이터베이스 쿼리 작성 가이드라인을 도입함.

코드 수정 후 UI 기능이 정상 작동하는지 확인하기 위해, 백테스트 웹 서비스 재시작 및 브라우저 검증에 대한 새로운 규칙을 구현함.

시스템 전반의 무결성(Integrity)을 유지하기 위해 실전 매매(Live trading), 웹 백테스팅, 파라미터 탐색(Parameter searches) 간의 일관성 검사(Consistency checks) 체계를 확립함.

기대 효과 (Impact):

이러한 개선 사항들은 트레이딩 시스템의 견고성(Robustness)과 신뢰성을 향상시키며, 에러 발생을 최소화하고 다양한 시스템 컴포넌트 간의 원활한 상호작용을 보장함.
2026-07-17 01:09:09 +09:00

300 lines
11 KiB
Python

#!/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:
from kis_trader.backtest.param_search_dates import resolve_param_search_range
week_ago, today = resolve_param_search_range("RANGE_BREAK", lookback_days=7)
parser = argparse.ArgumentParser(description="박스권 돌파 백테스트 파라미터 Grid Search")
parser.add_argument("--start", default=week_ago, help="시작일 (거래일 보정)")
parser.add_argument("--end", default=today, help="종료일 (주말·휴장이면 이전 장운영일)")
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())