Files
kis_bot/kis_trader/backtest/param_search.py
Hwang f61c471aac 브랜치 분리 방식: A / B / C
A 선택 시 커밋 메시지: 위 초안 OK / 수정 / 직접 작성
작업 시점: 지금 / 운영 데이터 1~2일 쌓고 / 주말
2026-05-05 21:04:17 +09:00

836 lines
41 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
kis_trader/backtest/param_search.py — 스캘핑 백테스트 파라미터 자동 탐색 (Grid Search)
======================================================================================
실행:
cd /home/hoon/kis_bot
python3 kis_trader/backtest/param_search.py
# 또는 패키지 모듈로
python3 -m kis_trader.backtest.param_search --mode coarse --apply-ai
옵션:
--start 시작일 (기본: 오늘-7일)
--end 종료일 (기본: 오늘)
--mode 탐색 모드: coarse(기본) / fine / full / wide
wide = 급등·최소가격·방어ON/OFF 등 방어축을 넓게(조합 수 큼)
--top 상위 N개 출력 (기본: 5000)
--min_trades 최소 거래 건수 필터 (기본: 1)
--apply 결과 N위 조합을 DB에 자동 적용 (기본: False, 지정하면 N 생략 시 1)
--apply-ai Gemini 로 수익·승률 기준 조합 하나 선택해서 DB 자동 적용
설명:
웹 API를 거치지 않고 scalping_engine 을 직접 호출하여 속도를 극대화했습니다.
피뢰침 방지(high_chase_thr)·급등주 필터(max_daily_chg)·방어필터 ON/OFF 도 그리드에 포함됩니다.
위치 이관 (2026-04 기준):
backtest_scalping/param_search.py → kis_trader/backtest/param_search.py
- ROOT = kis_bot 프로젝트 루트 (__file__ 기준 3단계 위)
- 결과 저장: kis_trader/backtest/results/ (구 backtest_scalping/results 는 그대로 유지)
- scalping_engine / database.TradeDB 는 ROOT 에서 그대로 임포트
"""
import sys, os, json, time, argparse, signal
import heapq
from datetime import datetime, timedelta
from itertools import product
from concurrent.futures import ProcessPoolExecutor, as_completed
from typing import Optional, List, Dict, Any, Tuple
MIN_WIN_RATE_DEFAULT = 52.0
# ─────────────────────────────────────────────────────────────────────────────
# 멀티프로세싱 안전장치 — 부모(마스터)가 죽으면 워커도 자동으로 함께 종료
# ─────────────────────────────────────────────────────────────────────────────
# Linux 의 `PR_SET_PDEATHSIG` 를 이용해 "부모가 죽으면 이 시그널을 내게 보내라"
# 고 커널에 요청. Ctrl+C / SIGTERM / SIGKILL 어느 경로로 부모가 사라져도 워커가
# 고아(PPID=1) 로 남지 않는다.
def _worker_init() -> None:
"""ProcessPoolExecutor initializer — 워커 생성 직후 한 번 호출된다."""
try:
if sys.platform.startswith("linux"):
import ctypes
PR_SET_PDEATHSIG = 1
libc = ctypes.CDLL("libc.so.6", use_errno=True)
libc.prctl(PR_SET_PDEATHSIG, signal.SIGTERM, 0, 0, 0)
except Exception:
pass # 비 Linux 환경이거나 prctl 불가 시 무시 (치명적이지 않음)
# 워커 프로세스에서는 부모의 KeyboardInterrupt 에 간섭하지 않도록 SIGINT 무시.
# (부모가 시그널을 받아 shutdown 하는 동안 워커가 중간에 예외로 죽으면
# 결과 수집 로직이 혼잡해짐)
try:
signal.signal(signal.SIGINT, signal.SIG_IGN)
except Exception:
pass
# kis_bot 루트를 경로에 추가 (스크립트·패키지 실행 모두 대응)
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)
if HERE not in sys.path:
sys.path.insert(0, HERE)
import logging
logging.getLogger("TradeDB").setLevel(logging.WARNING) # 반복 초기화 로그 억제
from database import TradeDB
import scalping_engine as se
# ──────────────────────────────────────────────────────────────────────────────
# 스캘핑 기본값 = 엔진에서 DB 로드 (백테스트 API와 동일 단일 소스, 실매매와 동기화)
# ──────────────────────────────────────────────────────────────────────────────
def _fixed_defaults():
"""엔진 get_scalping_defaults_from_db() 사용. UI/파람서치용으로 %/비율 변환."""
_d = se.get_scalping_defaults_from_db()
return {
"rsi_period": _d["rsi_period"],
"rsi_overbought": _d.get("rsi_overbought", 75.0), # 과열 차단 (그리드 탐색 제외, 고정값)
"slot_money": _d["slot_money"],
"vol_mult": _d["vol_mult"],
"trail_trigger": _d["trail_trigger"] * 100, # % 단위
"trail_stop": _d["trail_stop"] * 100, # % 단위
"cooldown_min": _d["cooldown_min"],
"time_start_hm": _d["time_start_hm"],
"time_end_hm": _d["time_end_hm"],
"max_daily": _d["max_daily"],
"fee_rate": _d["fee_rate"] * 100, # % 단위
"sell_tax": _d["sell_tax"] * 100, # % 단위
# 방어로직
"high_chase_thr": _d["high_chase_thr"], # 비율 (0.96)
"max_daily_chg": _d["max_daily_chg"], # % 단위
"min_price": _d["min_price"],
"max_loss_krw": _d["max_loss_krw"],
"min_margin": _d["min_margin"] * 100, # % 단위
"use_defense_filters": _d.get("use_defense_filters", True),
}
# RSI_OVERSOLD별 JSON 저장 개수 (한 RSI에 치중되지 않도록 균등 분배)
PER_RSI_JSON = 20
# ──────────────────────────────────────────────────────────────────────────────
# 결과 디렉터리 (신규 위치 우선, 구 경로도 계속 조회 가능)
# ──────────────────────────────────────────────────────────────────────────────
def _results_dir_for_write() -> str:
"""새로 저장할 결과는 kis_trader/backtest/results/ 에 둔다."""
d = os.path.join(HERE, "results")
os.makedirs(d, exist_ok=True)
return d
def _results_dirs_for_read() -> List[str]:
"""읽기용 디렉터리: 신규 → 구 순서."""
return [
os.path.join(HERE, "results"),
os.path.join(ROOT, "backtest_scalping", "results"),
]
def _latest_json(prefix: str) -> Optional[str]:
"""읽기용 디렉터리에서 prefix 로 시작하는 가장 최근 JSON 경로 반환."""
best_path, best_mtime = None, -1.0
for d in _results_dirs_for_read():
if not os.path.isdir(d):
continue
for f in os.listdir(d):
if not (f.startswith(prefix) and f.endswith(".json")):
continue
p = os.path.join(d, f)
try:
m = os.path.getmtime(p)
except OSError:
continue
if m > best_mtime:
best_mtime = m
best_path = p
return best_path
# ──────────────────────────────────────────────────────────────────────────────
# 파라미터 그리드 (min_price 등은 DB 앵커 기반 — 고정 1값만 쓰지 않음)
# ──────────────────────────────────────────────────────────────────────────────
def _min_price_grid():
"""DB get_scalping_defaults 의 min_price 를 포함한 최소가격 스윕 (저·중·고가 후보)."""
mp = int(_fixed_defaults()["min_price"])
return sorted(set([500, 1000, max(500, mp), min(mp + 5000, 200000), 50000, 100000]))
def _scalp_grids():
"""탐색 모드별 그리드. 호출 시점 DB 기본값으로 min_price 티어가 잡힘."""
mp_list = _min_price_grid()
# fine: 조합 폭주 완화 — min_price 는 앵커·고가 두 티어 + 전 구간 5만원
fd_mp = int(_fixed_defaults()["min_price"])
min_price_fine = sorted(set([1000, fd_mp, 50000]))
return {
# 빠른 1차 — 트레일·손익·RSI 핵심 축 + 방어 ON/OFF.
# ⚠️ trail_trigger=0 포함 = "트레일 OFF" 경로. 이 경로에서는 tp_pct 가
# 실제 상한선으로 작동하므로 tp 값 탐색이 의미 있어진다.
# 조합 수: 6 × 3 × 4 × 3 × 3 × 2 × 2 × 1 × 1 × 2 × 1 × 1 = 5,184
"coarse": {
"rsi_oversold": [15, 18, 21, 24, 26, 28], # 저·중·고 RSI 대표
"sl_pct": [0.8, 1.2, 1.5], # 보수·중·공격
"tp_pct": [1.5, 2.5, 3.5, 5.0], # 트레일 OFF 시 상한선
"drop_rate": [1.0, 1.5, 2.0], # 얕은·중·깊은 눌림
"trail_trigger": [0, 0.5, 1.0], # 0 = 트레일 OFF (tp_pct 의미 있음)
"trail_stop": [0.3, 0.5], # 추적 폭 (trigger=0 이면 무의미하지만 무해)
"high_chase_thr": [0.96, 1.0], # 피뢰침 보수 / OFF
"max_daily_chg": [28.0], # 상승장 대응 (coarse 에서는 고정)
"min_price": [1000], # coarse 에서는 저가 고정
"use_defense_filters": [True, False], # 방어 ON / OFF
"max_loss_krw": [200000], # 중간값 고정 (wide 에서 세분화)
"min_drop_pct_for_loss_cut": [0.015], # 1.5% 고정 (wide 에서 세분화)
},
# 세밀 2차 (트레일·쿨다운 포함 + 최소가격·방어)
"fine": {
"rsi_oversold": [15, 17, 19, 21, 25],
"sl_pct": [0.8, 1.0, 1.2, 1.5],
"tp_pct": [1.5, 2.0, 2.5, 3.0, 3.5],
"drop_rate": [1.0, 1.5, 2.0],
"trail_trigger": [0, 0.5, 0.8],
"trail_stop": [0.3, 0.4, 0.5],
"cooldown_min": [5, 10],
"high_chase_thr": [0.96, 0.98],
"max_daily_chg": [15.0, 20.0, 25.0],
"min_price": min_price_fine,
"use_defense_filters": [True, False],
"max_loss_krw": [150000, 200000, 300000],
"min_drop_pct_for_loss_cut": [0.01, 0.015, 0.02],
},
# 전체 탐색 (매우 오래 걸림)
"full": {
"rsi_oversold": [15, 17, 20, 25],
"sl_pct": [0.8, 1.0, 1.2, 1.5, 2.0],
"tp_pct": [1.5, 2.0, 2.5, 3.0, 4.0],
"drop_rate": [0.8, 1.0, 1.5, 2.0, 2.5],
"trail_trigger": [0, 0.5, 0.8, 1.0],
"trail_stop": [0.3, 0.4, 0.5, 0.8],
"cooldown_min": [5, 10],
"high_chase_thr": [0.96, 0.98, 1.0],
"max_daily_chg": [15.0, 20.0, 30.0],
"min_price": mp_list,
"use_defense_filters": [True, False],
"max_loss_krw": [100000, 200000, 300000],
"min_drop_pct_for_loss_cut": [0.01, 0.015, 0.02, 0.025],
},
# 방어축 광범위 (RSI·슬립은 coarse 보다 약간 줄이고 급등·min_price·방어를 넓게)
"wide": {
"rsi_oversold": [15, 17, 19, 21, 23, 25, 28],
"sl_pct": [0.8, 1.0, 1.2, 1.5],
"tp_pct": [1.5, 2.0, 2.5, 3.0],
"drop_rate": [1.0, 1.5, 2.0, 2.5],
"high_chase_thr": [0.96, 0.98, 1.0],
"max_daily_chg": [8.0, 12.0, 16.0, 20.0, 25.0, 30.0, 40.0],
"min_price": mp_list,
"use_defense_filters": [True, False],
"max_loss_krw": [100000, 200000, 300000],
"min_drop_pct_for_loss_cut": [0.01, 0.015, 0.02],
},
}
def _get_scalp_field_map():
"""스캘핑 파라미터 → env_config 컬럼 매핑 (apply / db_snapshot 공용). 웹·봇과 동일 키 저장."""
return {
"rsi_oversold": ("SCALP_RSI_OVERSOLD", lambda v: str(int(v))),
"rsi_overbought": ("SCALP_RSI_OVERBOUGHT", lambda v: str(int(v))),
"sl_pct": ("SCALP_STOP_LOSS_PCT", lambda v: str(float(v) / 100)),
"tp_pct": ("SCALP_TAKE_PROFIT_PCT", lambda v: str(float(v) / 100)),
"drop_rate": ("SCALP_MIN_DROP_RATE", lambda v: str(float(v) / 100)),
"trail_trigger": ("SCALP_ATR_UP_MULT", lambda v: str(float(v))),
"trail_stop": ("SCALP_ATR_DOWN_MULT", lambda v: str(float(v))),
"cooldown_min": ("SCALP_COOLDOWN_SEC", lambda v: str(int(float(v)) * 60)),
# 스캘핑 전용 방어로직 (꼬리잡기와 값 분리)
"high_chase_thr": ("SCALP_HIGH_PRICE_CHASE_THRESHOLD", lambda v: str(float(v))),
"max_daily_chg": ("SCALP_MAX_DAILY_CHANGE_PCT", lambda v: str(float(v))),
"min_price": ("SCALP_MIN_PRICE", lambda v: str(int(float(v)))),
"max_loss_krw": ("SCALP_MAX_LOSS_PER_TRADE_KRW", lambda v: str(int(float(v)))),
"min_drop_pct_for_loss_cut": ("SCALP_MIN_DROP_PCT_FOR_LOSS_CUT", lambda v: str(round(float(v)*100, 2)) if float(v) < 1 else str(round(float(v), 2))), # % (1.5)
"min_margin": ("SCALP_MIN_PROFIT_PCT", lambda v: str(float(v))), # % 단위 (0.2 등)
"use_defense_filters": ("SCALP_USE_DEFENSE_FILTERS", lambda v: "true" if v else "false"),
}
def _params_to_db_snapshot(params: dict) -> dict:
"""그리드 params(표시 단위) → env_config 컬럼명:값 문자열 dict."""
field_map = _get_scalp_field_map()
return {
db_col: fmt(params[param_k])
for param_k, (db_col, fmt) in field_map.items()
if param_k in params
}
def _apply_from_latest_json(rank: int):
"""최근 search_*.json에서 rank번째(1-based) 항목의 merged_params를 DB에 적용."""
latest_path = _latest_json("search_")
if not latest_path:
print("⚠️ search_*.json 파일이 없습니다 (신규/구 경로 모두).")
return
with open(latest_path, "r", encoding="utf-8") as f:
data = json.load(f)
top = data.get("top") or []
if rank < 1 or rank > len(top):
print(f"⚠️ 순번 {rank}이(가) 유효하지 않습니다. (1~{len(top)})")
return
item = top[rank - 1]
merged = item.get("merged_params")
if not merged:
merged = item.get("params")
if not merged:
print("⚠️ 해당 항목에 merged_params/params가 없습니다.")
return
if item.get("total_pnl", 0) <= 0:
print(f"⚠️ {rank}번째 결과는 총손익 ≤ 0 (조건 미충족). DB 미적용. 기존 설정 유지.")
return
print(f"📂 {latest_path} 에서 {rank}번째 적용합니다.")
_apply_to_db(merged)
def _ui_to_engine_params(ui_params: dict) -> dict:
"""UI 표시용(% 등) → 엔진용 비율 단위. 워커에서 공통 사용."""
engine_params = dict(ui_params)
engine_params["sl_pct"] = ui_params["sl_pct"] / 100
engine_params["tp_pct"] = ui_params["tp_pct"] / 100
engine_params["drop_rate"] = ui_params["drop_rate"] / 100
engine_params["trail_trigger"] = ui_params["trail_trigger"] / 100
engine_params["trail_stop"] = ui_params["trail_stop"] / 100
engine_params["fee_rate"] = ui_params["fee_rate"] / 100
engine_params["sell_tax"] = ui_params["sell_tax"] / 100
engine_params["min_margin"] = ui_params.get("min_margin", 0.2) / 100
if "use_defense_filters" in ui_params:
engine_params["use_defense_filters"] = bool(ui_params["use_defense_filters"])
# 👇 [핵심] 손절 퍼센트에 맞춰 1회 투자금(slot_money) 자동 계산 (봇과 동일 공식)
# 5억 고정이면 수수료만으로 손절컷 걸려 좋은 조합(RSI 17 등)이 버려짐 → max_loss_krw/sl_pct 로 보정
max_loss = engine_params.get("max_loss_krw", 200000)
if engine_params["sl_pct"] > 0 and max_loss > 0:
engine_params["slot_money"] = max_loss / engine_params["sl_pct"]
return engine_params
def _evaluate_scalp_chunk(
param_chunk: List[Dict[str, Any]],
base_fixed: Dict[str, Any],
keys: List[str],
codes_candles: Dict[str, List[Dict]],
min_trades: int,
min_win_rate: float,
top_n: int,
universe_by_slot: Optional[Dict[str, List[str]]] = None,
) -> List[Tuple[float, float, int, Dict]]:
"""워커: 청크 내 조합 평가 후 승률→수익 순 상위 top_n개만 힙으로 반환.
universe_by_slot이 있으면 실매매와 동일하게 5분마다 후보 종목만 매수 검사."""
local_heap: List[Tuple[float, float, int, Dict]] = []
for combo in param_chunk:
ui_params = dict(base_fixed)
ui_params.update(combo)
engine_params = _ui_to_engine_params(ui_params)
trades = se.run_scalping_backtest(
codes_candles, engine_params, universe_by_slot=universe_by_slot
)
total_trades = len(trades)
if total_trades < min_trades:
continue
total_pnl = sum(t["pnl"] for t in trades)
wins = sum(1 for t in trades if t["pnl"] > 0)
win_rate = (wins / total_trades * 100) if total_trades else 0
win_pnl = sum(t["pnl"] for t in trades if t["pnl"] > 0)
loss_pnl = sum(t["pnl"] for t in trades if t["pnl"] < 0)
pf = round(abs(win_pnl / loss_pnl), 2) if loss_pnl != 0 else 9999.0
avg_hold = (sum(t["hold_min"] for t in trades) / total_trades) if total_trades else 0
peak, mdd, cum = 0.0, 0.0, 0.0
for t in trades:
cum += t["pnl"]
if cum > peak: peak = cum
dd = peak - cum
if dd > mdd: mdd = dd
# merged_params = 실제 엔진에 넘긴 값 반영 (slot_money는 손절%에 맞춰 계산된 값 저장)
merged = dict(ui_params)
if "slot_money" in engine_params:
merged["slot_money"] = engine_params["slot_money"]
result_pkg = {
"params": {k: ui_params[k] for k in keys},
"total_pnl": int(total_pnl),
"win_rate": round(win_rate, 2),
"total_trades": total_trades,
"pf": round(pf, 2),
"avg_hold": round(avg_hold, 1),
"mdd": round(mdd),
"merged_params": merged,
}
if len(local_heap) < top_n:
heapq.heappush(local_heap, (win_rate, total_pnl, id(result_pkg), result_pkg))
else:
heapq.heappushpop(local_heap, (win_rate, total_pnl, id(result_pkg), result_pkg))
return local_heap
def _load_candles_for_search(start: str, end: str, rsi_period: int) -> dict:
"""엔진 직접 타격을 위해 DB에서 캔들을 한 번만 메모리에 로드합니다."""
db = TradeDB()
codes_candles = {}
try:
start_key = (start.replace("-", "") + "0000") if start else "20260101"
end_key = (end.replace("-", "") + "2359") if end else "99991231"
codes_raw = db.conn.execute(
"SELECT DISTINCT code FROM ws_candles WHERE timeframe=1 "
"AND candle_time >= %s AND candle_time <= %s ORDER BY code",
[start_key, end_key]
).fetchall()
codes = [r["code"] for r in codes_raw]
for code in codes:
rows = db.conn.execute(
"SELECT candle_time, open, high, low, close, volume "
"FROM ws_candles "
"WHERE timeframe=1 AND code=%s "
"AND candle_time >= %s AND candle_time <= %s "
"AND is_confirmed=1 "
"ORDER BY candle_time ASC",
[code, start_key, end_key]
).fetchall()
if len(rows) < rsi_period + 5:
continue
codes_candles[code] = [dict(r) for r in rows]
finally:
db.close()
return codes_candles
def run_search(start: str, end: str, mode: str, top_n: int,
min_trades: int, min_win_rate: float, apply_rank: Optional[int], from_file_only: bool,
sort_by: str = "pnl", use_fallback_universe: bool = False) -> bool:
"""탐색 실행. 결과가 있어서 JSON 저장까지 했으면 True, 조건 만족 조합 없이 조기 return 시 False."""
if from_file_only and apply_rank is not None and apply_rank >= 1:
_apply_from_latest_json(apply_rank)
return True
grid = _scalp_grids()[mode]
keys = list(grid.keys())
combos = list(product(*[grid[k] for k in keys]))
total = len(combos)
print(f"\n[{mode.upper()} 모드] 탐색 조합: {total:,}개 | 기간: {start} ~ {end}")
print(f"📌 1위 정렬 기준: {'총손익 최대 (수익 나는 조합 우선)' if sort_by == 'pnl' else '승률 최대'}")
print("=" * 70)
results = []
t0 = time.time()
FIXED_DEFAULTS = _fixed_defaults()
# 캔들 데이터를 메모리에 1회 로드 (워커에 전달)
print("⏳ DB에서 캔들 데이터를 메모리로 불러오는 중...")
codes_candles = _load_candles_for_search(start, end, FIXED_DEFAULTS.get("rsi_period", 3))
print(f"✅ 데이터 로드 완료: {len(codes_candles)}종목")
# 유니버스: --fallback-universe 이면 이력 무시하고 시뮬레이션만 사용 (조합별 거래 수 확대)
# 신봇 기준:
# * 실매매는 10초 REST 폴링 + 변동 tick 마다 초단위 event_time 으로 저장.
# * 백테스트는 TradeDBExt.get_universe_by_candle_time("SCALP", ...) 로
# 1분 캔들 시각 키를 가진 dict 로 받아 엔진에 그대로 주입.
# * fallback 시뮬레이션은 1분봉 근사 점수 기반 5분 버킷팅 (과거 호환).
universe_by_slot = None
fallback_sim_interval = 5 # --fallback-universe 전용 시뮬레이션 버킷 (분)
start_ymd = start.replace("-", "") if start else ""
end_ymd = end.replace("-", "") if end else ""
if use_fallback_universe:
print("📌 [유니버스] --fallback-universe: 저장 이력 무시 → 시뮬레이션 유니버스 (조합별 거래 수 확대)")
elif start_ymd and end_ymd:
try:
# 신봇 이력 (초단위 event_time) → 1분 캔들 시각으로 리샘플링
from kis_trader.database.db_manager import get_db as _get_ext_db # type: ignore
_ext = _get_ext_db()
history = _ext.get_universe_by_candle_time(
strategy_id="SCALP",
start_ymd=start_ymd,
end_ymd=end_ymd,
)
if history:
universe_by_slot = history
n_bins = len(history)
avg = sum(len(v) for v in history.values()) / max(1, n_bins)
print(
f"✅ 유니버스: 신봇 이력 사용 (event_time → 1분 캔들 리샘플링) | "
f"{n_bins:,}분봉 · 평균 {avg:.1f}종목 (SCALP)"
)
print(
"📌 [유니버스] 이력만 쓰면 매수 기회 적어 거래 0~1건 나올 수 있음. "
"조합 많을 때는 --fallback-universe 권장."
)
else:
universe_by_slot = None
except Exception as _e:
logging.getLogger("param_search").debug(
"신봇 유니버스 이력 조회 스킵: %s", _e,
)
universe_by_slot = None
# 엔진이 쓸 슬롯 단위 결정:
# * 신봇 이력 경로 → 1분봉 키(=passthrough)
# * 시뮬 fallback → fallback_sim_interval 분 버킷
engine_scan_interval_min = 1
if universe_by_slot is None:
universe_top_n = int(os.environ.get("UPDATE_UNIVERSE_TOP_N", "20"))
universe_min_score = float(os.environ.get("UPDATE_UNIVERSE_MIN_SCORE", "4.0"))
universe_by_slot = se.build_universe_simulation(
codes_candles,
top_n=universe_top_n,
min_score=universe_min_score,
scan_interval_min=fallback_sim_interval,
)
engine_scan_interval_min = fallback_sim_interval
n_slots = len(universe_by_slot)
avg_per_slot = sum(len(c) for c in universe_by_slot.values()) / max(1, n_slots)
print(
f"✅ 유니버스: 시뮬레이션 사용 (이력 없음) | "
f"{fallback_sim_interval}분 슬롯 {n_slots}개 · 슬롯당 평균 {avg_per_slot:.1f}종목"
)
print("📌 [유니버스] 서치는 '시뮬레이션 유니버스' 기준입니다. (신봇 이력 없음)")
# 엔진에 슬롯 단위 주입 (engine._slot_key 가 이 값으로 캔들 시각 정규화)
FIXED_DEFAULTS["scan_interval_min"] = engine_scan_interval_min
# 조합을 딕셔너리 리스트로 변환 후 청크 분할
dict_combos = [dict(zip(keys, c)) for c in combos]
n_cpu = os.cpu_count() or 4
# 스캘핑·꼬리잡기 두 서치를 동시에 돌려도 합산 80% 가 되도록 40% 로 유지.
# (단독 실행 시 CPU 절반 놀지만, 하루 1회 자동 최적화 파이프라인 보호가 우선)
max_workers = max(1, int(n_cpu * 0.4))
# [핵심 수정] 청크 크기를 최대 300개로 제한하여 진행률 표시가 실시간으로 업데이트되도록 함
chunk_size = min(300, max(50, total // (max_workers * 4)))
chunks = [dict_combos[i:i + chunk_size] for i in range(0, len(dict_combos), chunk_size)]
print(f"⚙️ 멀티프로세싱 시작 (코어: {n_cpu}, 워커: {max_workers} ≈ 80%%) | 청크: {len(chunks):,}")
start_time = time.time()
global_heap: List[Tuple[float, float, Tuple[int, int], Dict]] = []
# initializer=_worker_init 로 워커에게 PR_SET_PDEATHSIG 설정
# → 부모가 SIGKILL·SIGTERM 어느 경로로 죽어도 워커가 고아로 남지 않음.
with ProcessPoolExecutor(max_workers=max_workers, initializer=_worker_init) as executor:
futures = {
executor.submit(
_evaluate_scalp_chunk, chunk, FIXED_DEFAULTS, keys, codes_candles,
min_trades, min_win_rate, top_n, universe_by_slot
): chunk for chunk in chunks
}
combos_per_chunk = chunk_size
print(f"⏳ 청크 처리 중… (청크당 최대 {combos_per_chunk:,}개 조합, 완료되는 대로 진행률·ETA 출력)")
processed = 0
use_carriage_return = sys.stdout.isatty()
for future in as_completed(futures):
processed += 1
local_results = future.result()
for i, item in enumerate(local_results):
wr, pnl, _, result_pkg = item
# sort_by pnl: 1위 = 총손익 최대 (pnl 로 넣어서 min-heap에서 수익 큰 게 뒤로)
# sort_by win_rate: 1위 = 승률 최대 (기존)
if sort_by == "pnl":
unique_item = (-pnl, wr, (processed, i), result_pkg)
else:
unique_item = (wr, pnl, (processed, i), result_pkg)
if len(global_heap) < top_n:
heapq.heappush(global_heap, unique_item)
else:
heapq.heappushpop(global_heap, unique_item)
# ── 정확한 ETA(예상 남은 시간) 계산 로직 ──
progress = (processed / len(chunks)) * 100
elapsed_so_far = time.time() - start_time
# 누적 소요 시간을 처리된 청크 개수로 나누어 1청크당 평균 처리 시간 도출
avg_time_per_chunk = elapsed_so_far / processed
remaining_chunks = len(chunks) - processed
eta_sec = avg_time_per_chunk * remaining_chunks
# 초 단위 포맷팅 (시간/분/초)
eta_m, eta_s = divmod(int(eta_sec), 60)
eta_h, eta_m = divmod(eta_m, 60)
if eta_h > 0:
eta_str = f"{eta_h}시간 {eta_m}{eta_s}"
elif eta_m > 0:
eta_str = f"{eta_m}{eta_s}"
else:
eta_str = f"{eta_s}"
elapsed_m, elapsed_s = divmod(int(elapsed_so_far), 60)
eta_msg = f" | 경과: {elapsed_m}{elapsed_s}초 | 남은시간: {eta_str}"
line = f"⏳ 진행률: {progress:.1f}% ({processed:,}/{len(chunks):,} 청크 완료){eta_msg}"
if use_carriage_return:
print(f"\r{line}", end="", flush=True)
else:
print(line, flush=True)
if use_carriage_return:
print(flush=True) # 줄바꿈으로 진행률 줄 마무리
elapsed = time.time() - start_time
if not global_heap:
print("⚠️ 조건을 만족하는 조합이 없습니다. (min_trades를 낮추거나 기간을 늘려보세요.)")
print("📌 DB 미적용. 기존 설정 유지.")
return False
# 힙: sort_by pnl → (-pnl, wr) 최소힙 → heappop 순이 이미 [best pnl, ..., worst] 이므로 reverse 금지
# sort_by win_rate → (wr, pnl) 최소힙 → heappop 순은 [low wr, ..., high wr] 이므로 reverse 필요
results = [heapq.heappop(global_heap)[3] for _ in range(len(global_heap))]
if sort_by == "win_rate":
results.reverse()
# RSI_OVERSOLD 기준으로 균등 분배 → JSON에 RSI별 PER_RSI_JSON개씩 (한 RSI에 치중 방지)
if "rsi_oversold" in keys and results:
rsi_vals = sorted(set(r["params"]["rsi_oversold"] for r in results))
by_rsi: Dict[Any, List[Dict]] = {}
for r in results:
v = r["params"]["rsi_oversold"]
if v not in by_rsi:
by_rsi[v] = []
if len(by_rsi[v]) < PER_RSI_JSON:
by_rsi[v].append(r)
results = []
for v in rsi_vals:
results.extend(by_rsi.get(v, []))
# 손익 순 유지, 동점이면 RSI 낮은 쪽(과매도 강함) 우선
results.sort(key=lambda r: (-r["total_pnl"], r["params"]["rsi_oversold"], -r["win_rate"]))
print(f"✅ RSI별 상위 {PER_RSI_JSON}개씩 보장 → {len(results)}건 (총 {len(rsi_vals)}가지 RSI)")
# 승률 min_win_rate 이상만 우선; 없으면 차악
filtered = [r for r in results if r["win_rate"] >= min_win_rate]
if filtered:
results = filtered
order_msg = "수익→승률 순" if sort_by == "pnl" else "승률→수익 순"
print(f"✅ 승률 {min_win_rate}% 이상 {len(results)}건 중 {order_msg} 상위 사용")
else:
print(f"⚠️ 승률 {min_win_rate}% 이상 없음 → 차악(상위) 적용")
# 손익 마이너스인 조합 제외 (수익 나는 것만 표시·저장)
profitable = [r for r in results if r["total_pnl"] > 0]
if profitable:
results = profitable
print(f"✅ 총손익 플러스만 사용: {len(results)}건 (손실 조합 제외)")
else:
print(f"⚠️ 수익 나는 조합 없음 → 손실 최소 순으로 표시")
print(f"\n완료: {elapsed:.1f}초 | 유효 결과: {len(results)}")
# ── 결과 출력 ──
hdr_keys = [k for k in keys]
col_w = max(len(k) for k in hdr_keys) + 2
order_label = "수익" if sort_by == "pnl" else "승률"
print(f"\n{'='*90}")
print(f" 🏆 {order_label} TOP {min(top_n, len(results))} (투자금 1,000,000원 기준)")
print(f"{'='*90}")
# 헤더
hdr = " ".join(f"{k:>{col_w}}" for k in hdr_keys)
print(f"{hdr} | {'손익(원)':>12} {'승률':>6} {'거래':>5} {'PF':>5} {'보유':>6}")
print("-" * (len(hdr) + 55))
for r in results[:top_n]:
p = r["params"]
row = " ".join(
(f"{str(p[k]):>{col_w}}" if isinstance(p[k], bool) else f"{p[k]:>{col_w}.4g}")
for k in hdr_keys
)
print(f"{row} | {r['total_pnl']:>+12,.0f} {r['win_rate']:>5.1f}% "
f"{r['total_trades']:>5} {r['pf']:>5.2f} {r['avg_hold']:>5.1f}")
best = results[0]
bp = best["params"]
print(f"""
╔══════════════════════════════════════════╗
║ 🏆 1위 최적 파라미터 ║
╠══════════════════════════════════════════╣""")
_disp_map = {
"rsi_oversold": "SCALP_RSI_OVERSOLD",
"rsi_overbought": "SCALP_RSI_OVERBOUGHT",
"sl_pct": "SCALP_STOP_LOSS_PCT (÷100)",
"tp_pct": "SCALP_TAKE_PROFIT_PCT(÷100)",
"drop_rate": "SCALP_MIN_DROP_RATE (÷100)",
"trail_trigger": "SCALP_ATR_UP_MULT (트레일발동%)",
"trail_stop": "SCALP_ATR_DOWN_MULT(트레일추적%)",
"cooldown_min": "SCALP_COOLDOWN_SEC(÷60=분)",
"high_chase_thr": "HIGH_PRICE_CHASE_THRESHOLD",
"max_daily_chg": "MAX_DAILY_CHANGE_PCT",
"max_loss_krw": "MAX_LOSS_PER_TRADE_KRW(원)",
"min_price": "SCALP_MIN_PRICE(원)",
"use_defense_filters": "SCALP_USE_DEFENSE_FILTERS",
}
for k, v in bp.items():
label = _disp_map.get(k, k)
print(f"{label:<30s} : {v!s:>6}")
print(f"""╠══════════════════════════════════════════╣
║ 총 손익 : {best['total_pnl']:>+12,.0f} 원 ║
║ 승률 : {best['win_rate']:>6.1f}% ║
║ 총 거래 : {best['total_trades']:>5} 건 ║
║ Profit Factor : {best['pf']:>5.2f}
║ 평균 보유 : {best['avg_hold']:>5.1f} 분 ║
║ 최대 낙폭(MDD): {best['mdd']:>12,.0f} 원 ║
╚══════════════════════════════════════════╝""")
# JSON 저장용: RSI별 200개씩 모은 전체 리스트 (콘솔 TOP은 여전히 top_n만 출력)
top_list = []
for idx, r in enumerate(results):
p = r["params"]
merged = r.get("merged_params", p)
top_list.append({
"rank": idx + 1,
"params": p,
"merged_params": merged,
"db_snapshot": _params_to_db_snapshot(merged),
"total_pnl": r["total_pnl"],
"win_rate": r["win_rate"],
"total_trades": r["total_trades"],
"pf": r["pf"],
"avg_hold": r["avg_hold"],
"mdd": r["mdd"],
})
# ── JSON 결과 저장 (kis_trader/backtest/results/) ──
# 권한 문제 대비: 쓰기 실패 시 사용자 홈(~/.kis_bot_search_results/)으로 폴백.
out_dir = _results_dir_for_write()
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
out_path = os.path.join(out_dir, f"search_{mode}_{ts}.json")
payload = {
"mode": mode,
"start": start,
"end": end,
"min_win_rate": min_win_rate,
"top": top_list,
}
try:
with open(out_path, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
print(f"\n💾 결과 저장: {out_path}")
except (PermissionError, OSError) as _e:
fallback_dir = os.path.join(os.path.expanduser("~"), ".kis_bot_search_results")
os.makedirs(fallback_dir, exist_ok=True)
out_path = os.path.join(fallback_dir, f"search_{mode}_{ts}.json")
with open(out_path, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
print(f"\n⚠️ 기본 경로({out_dir}) 쓰기 실패({type(_e).__name__}). 폴백 저장: {out_path}")
print(f" 권한 복구: sudo chown -R $USER:$USER {out_dir}")
# ── DB 자동 적용 (만족 조건: 총손익 > 0. 미충족 시 기존 설정 유지) ──
if apply_rank is not None and apply_rank >= 1 and apply_rank <= len(top_list):
cand = top_list[apply_rank - 1]
if cand.get("total_pnl", 0) <= 0:
print(f"⚠️ {apply_rank}번째 결과는 총손익 ≤ 0 (조건 미충족). DB 미적용. 기존 설정 유지.")
else:
_apply_to_db(cand["merged_params"])
print(f"{apply_rank}번째 결과 적용 완료")
return True
def _apply_to_db(best_params: dict):
"""1위 파라미터를 env_config DB에 자동 반영."""
field_map = _get_scalp_field_map()
sets = []
vals = []
for param_k, val in best_params.items():
if param_k not in field_map:
continue
db_col, fmt = field_map[param_k]
sets.append(f"{db_col} = %s")
vals.append(fmt(val))
if not sets:
print("DB 적용할 파라미터가 없습니다.")
return
db = TradeDB()
db.conn.execute(f"UPDATE env_config SET {', '.join(sets)}", vals)
db.conn.commit()
db.close()
print("\n✅ DB env_config 자동 적용 완료:")
for param_k, val in best_params.items():
if param_k in field_map:
db_col, fmt = field_map[param_k]
print(f" {db_col:<30s} = {fmt(val)}")
# ──────────────────────────────────────────────────────────────────────────────
# CLI 진입점
# ──────────────────────────────────────────────────────────────────────────────
def main():
today = datetime.now().strftime("%Y-%m-%d")
week_ago = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d")
parser = argparse.ArgumentParser(description="스캘핑 백테스트 파라미터 Grid Search")
parser.add_argument("--start", default=week_ago, help="시작일 (YYYY-MM-DD)")
parser.add_argument("--end", default=today, help="종료일 (YYYY-MM-DD)")
parser.add_argument("--mode", default="coarse", choices=["coarse", "fine", "full", "wide"],
help="탐색 모드: coarse / fine / full / wide(방어·min_price 광범위)")
parser.add_argument("--top", default=5000, type=int, help="상위 N개 출력·JSON 저장 (기본 5000)")
parser.add_argument("--min_trades", default=1, type=int, help="최소 거래 건수")
parser.add_argument("--min_win_rate", default=MIN_WIN_RATE_DEFAULT, type=float, help="승률 하한 (%%). 이 이상만 손익순 1위")
parser.add_argument("--apply", nargs="?", const=1, type=int, default=None, metavar="N",
help="N번째 결과를 DB에 적용 (기본 1). --from-file 시 최근 JSON에서 적용")
parser.add_argument("--from-file", action="store_true", help="--apply N 과 함께 사용 시, 최근 결과 JSON에서만 적용 (탐색 생략)")
parser.add_argument("--sort-by", default="pnl", choices=["pnl", "win_rate"],
help="1위 기준: pnl=총손익 최대(기본), win_rate=승률 최대")
parser.add_argument("--fallback-universe", action="store_true", dest="fallback_universe",
help="저장 이력 무시, 시뮬레이션 유니버스만 사용. 조합 많을 때 거래 수 확대용")
parser.add_argument("--apply-ai", action="store_true", dest="apply_ai",
help="Gemini가 수익·승률 기준으로 하나 골라 DB 적용. --from-file 과 함께 쓰면 탐색 없이 최근 JSON만 사용; 그 외에는 탐색 완료 후 방금 생성된 JSON으로 적용")
args = parser.parse_args()
# 탐색 없이 최근 JSON으로만 AI 적용 (--from-file --apply-ai)
if args.apply_ai and args.from_file:
import param_apply_ai
param_apply_ai.apply_ai_scalp()
return
# SIGTERM 을 KeyboardInterrupt 와 동일하게 처리 (systemd·운영자 kill 대응)
def _sigterm_to_kbd(_sig, _frm):
raise KeyboardInterrupt("SIGTERM 수신 → 워커 정리 후 종료")
try:
signal.signal(signal.SIGTERM, _sigterm_to_kbd)
except Exception:
pass
try:
had_results = run_search(
start = args.start,
end = args.end,
mode = args.mode,
top_n = args.top,
min_trades = args.min_trades,
min_win_rate = args.min_win_rate,
apply_rank = args.apply,
from_file_only = args.from_file,
sort_by = args.sort_by,
use_fallback_universe = args.fallback_universe,
)
except KeyboardInterrupt as e:
# ProcessPoolExecutor 의 `with ... as executor:` 블록이 __exit__ 시
# shutdown(wait=True) 을 호출하고, 워커들은 PR_SET_PDEATHSIG 로 이미
# SIGTERM 을 받은 상태라 빠르게 정리된다.
print(f"\n{e} — 미완료 결과 없이 종료합니다.", flush=True)
sys.exit(130) # 130 = SIGINT 관례 exit code
# 탐색에서 조건 만족 조합이 있었을 때만 방금 저장된 JSON으로 AI 적용 (없으면 기존 설정 유지)
if args.apply_ai and had_results:
import param_apply_ai
param_apply_ai.apply_ai_scalp()
elif args.apply_ai and not had_results:
print("📌 이번 탐색에서 조건 만족 조합 없음 → apply_ai 스킵. DB 미적용. 기존 설정 유지.")
if __name__ == "__main__":
main()