변경 사항 (Changes): 구문 오류(Syntax error) 및 토큰 낭비를 방지하기 위해 에이전트 쉘(Agent shell)과 파이썬 코드 스니펫에 다수의 신규 안전 규칙(Safety rules)을 추가함. 스키마 검증 및 적절한 SQL 포맷팅을 보장하기 위해 임시(Ad-hoc) 데이터베이스 쿼리 작성 가이드라인을 도입함. 코드 수정 후 UI 기능이 정상 작동하는지 확인하기 위해, 백테스트 웹 서비스 재시작 및 브라우저 검증에 대한 새로운 규칙을 구현함. 시스템 전반의 무결성(Integrity)을 유지하기 위해 실전 매매(Live trading), 웹 백테스팅, 파라미터 탐색(Parameter searches) 간의 일관성 검사(Consistency checks) 체계를 확립함. 기대 효과 (Impact): 이러한 개선 사항들은 트레이딩 시스템의 견고성(Robustness)과 신뢰성을 향상시키며, 에러 발생을 최소화하고 다양한 시스템 컴포넌트 간의 원활한 상호작용을 보장함.
300 lines
11 KiB
Python
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())
|