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

709 lines
35 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/tail_param_search.py — 꼬리잡기 백테스트 파라미터 자동 탐색 (Grid Search)
==============================================================================================
tail_engine 을 직접 임포트해 run_tail_backtest 호출. 기본값은 DB(env_config) 단일 소스.
실매매·백테스트·파라서치가 동일한 env 값을 사용해 결과 예측 가능.
[V3 통합]: MA20, 피뢰침, ATR 동적 배수, 리스크 비율 등 고급 방어 로직 변수 탐색 추가.
[성능 최적화]: 멀티프로세싱(ProcessPool) 및 Heapq 기반 메모리 최적화 적용.
실행:
cd /home/hoon/kis_bot
python3 kis_trader/backtest/tail_param_search.py
# 또는
python3 -m kis_trader.backtest.tail_param_search --mode full --apply
옵션:
--start 시작일 (기본: 오늘-7일)
--end 종료일 (기본: 오늘)
--mode 탐색 모드: coarse / fine / full / massive (기본: coarse)
--top 상위 N개 출력·JSON 저장 (기본: 5000)
--min_trades 최소 거래 건수 필터 (기본: 1)
--min_win_rate 승률 하한 (기본: 45.0).
--apply [N] 1위(또는 N위) 결과를 DB에 적용. N 생략 시 1.
--from-file --apply N 과 함께 사용 시, 최근 결과 JSON에서 N번째 적용 (탐색 생략).
위치 이관 (2026-04 기준):
backtest_scalping/tail_param_search.py → kis_trader/backtest/tail_param_search.py
- ROOT = kis_bot 프로젝트 루트 (__file__ 기준 3단계 위)
- 결과 저장: kis_trader/backtest/results/*.json
- tail_param_result.json: kis_trader/backtest/tail_param_result.json
(구 경로 backtest_scalping/tail_param_result.json 도 읽기 fallback)
"""
import sys
import os
import time
import json
import signal
import logging
import argparse
import itertools
import heapq
from datetime import datetime, timedelta
from concurrent.futures import ProcessPoolExecutor, as_completed
from typing import List, Dict, Any, Tuple, Optional
# ─────────────────────────────────────────────────────────────────────────────
# 멀티프로세싱 안전장치 — 부모(마스터)가 죽으면 워커도 자동으로 함께 종료
# ─────────────────────────────────────────────────────────────────────────────
# Linux `PR_SET_PDEATHSIG` 로 부모 종료 시 워커도 SIGTERM 을 받도록 설정.
# (Ctrl+C / kill 어느 경로든 워커가 고아 프로세스로 남지 않게 함)
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
# 부모가 SIGINT 처리를 하는 동안 워커는 중간 예외로 죽지 않도록 SIGINT 무시.
try:
signal.signal(signal.SIGINT, signal.SIG_IGN)
except Exception:
pass
# 프로젝트 루트 경로 추가 (database, tail_engine 등 임포트용)
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)
from database import TradeDB
import tail_engine as te
logging.basicConfig(level=logging.INFO, format='%(message)s')
logger = logging.getLogger("tail_param_search")
MIN_WIN_RATE_DEFAULT = 45.0
# ─────────────────────────────────────────────────────────────────────────────
# 결과 디렉터리 (신규 위치 우선, 구 경로 fallback)
# ─────────────────────────────────────────────────────────────────────────────
def _results_dir_for_write() -> str:
d = os.path.join(HERE, "results")
os.makedirs(d, exist_ok=True)
return d
def _tail_result_paths_for_read() -> List[str]:
"""tail_param_result.json 조회 후보. 신규 → 구 순서."""
return [
os.path.join(HERE, "tail_param_result.json"),
os.path.join(ROOT, "backtest_scalping", "tail_param_result.json"),
]
def _tail_result_path_for_write() -> str:
return os.path.join(HERE, "tail_param_result.json")
def _find_tail_result_json() -> Optional[str]:
for p in _tail_result_paths_for_read():
if os.path.isfile(p):
return p
return None
# ─────────────────────────────────────────────────────────────────────────────
# 파라미터 그리드 정의 (V3 방어 로직 포함)
# ─────────────────────────────────────────────────────────────────────────────
PARAM_GRIDS = {
# ─────────────────────────────────────────────────────────────────────
# [COARSE] 실제 결과에 영향 주는 축만 빠르게 훑는 1일 1회 탐색용
# ─────────────────────────────────────────────────────────────────────
# ⚠️ 꼬리잡기 엔진 특성:
# - 실제 손절가 = entry - ATR × stop_atr_mult (sl_pct 는 미사용)
# - 실제 익절가 = entry + ATR × target_atr_mult (tp_pct 는 미사용)
# - sl_pct 는 포지션 "수량 사이징" 계산에만 쓰임 → 여기선 고정
# - tp_pct 는 엔진에서 아예 안 씀 → 고정
# 따라서 탐색 축은 ATR 배수(stop/target) + 어깨컷(트레일링 스탑) + 진입필터.
#
# 총 조합: 3×2×3×3×3×3×3×3 = 4,374 (수 분 내 완료 예상)
"coarse": {
# ── 진입 필터 (어떤 꼬리 모양을 살 것인가) ─────────────
"min_drop_rate": [0.02, 0.03, 0.04], # 당일 낙폭(저가/시가) 최소값
"min_recovery_ratio": [0.35, 0.5], # 저가→현재 반등률 최소값
"tail_ratio_min": [1.0, 1.5, 2.0], # 아래꼬리/몸통 비율 최소값
# ── 청산 (실제 P&L 을 좌우하는 축) ────────────────────
"stop_atr_mult": [2.0, 2.5, 3.0], # 동적 손절 폭 (ATR 배수)
"target_atr_mult": [5.0, 8.0, 12.0], # 동적 익절 폭 (ATR 배수)
"shoulder_min_high": [0.015, 0.03, 0.05], # 어깨컷(트레일) 발동 수익 임계 — 0.05 에 가까울수록 OFF 효과
"shoulder_cut_pct": [0.02, 0.03, 0.05], # 어깨컷 추적 폭 (고점 대비 하락%)
# ── 자금 관리 ─────────────────────────────────────────
"max_loss_krw": [100000, 200000, 300000], # 1회 최대 손실(원)
# ── 고정 (엔진 미사용 또는 사이징 전용) ───────────────
"sl_pct": [0.02], # 포지션 수량 계산 전용 (실제 손절은 ATR)
"tp_pct": [0.05], # ⚠️ 꼬리잡기 엔진은 사용 안 함
"min_drop_pct_for_loss_cut": [0.015], # 금액손실컷 발동 최소 하락률
},
"fine": {
"min_drop_rate": [0.015, 0.02, 0.025, 0.03, 0.04],
"min_recovery_ratio": [0.35, 0.4, 0.45, 0.5, 0.6],
"tail_ratio_min": [1.0, 1.2, 1.5, 1.8, 2.0],
"tail_pct_min": [0.001, 0.002, 0.003, 0.005],
"sl_pct": [0.015, 0.02, 0.025, 0.03, 0.04],
"tp_pct": [0.03, 0.04, 0.05, 0.06, 0.07],
"shoulder_cut_pct": [0.02, 0.025, 0.03, 0.04],
"rsi_threshold": [72, 75, 78, 82],
"max_loss_krw": [150000, 200000, 300000],
"min_drop_pct_for_loss_cut": [0.01, 0.015, 0.02],
},
"full": {
"min_drop_rate": [0.015, 0.02, 0.025, 0.03, 0.04, 0.05],
"min_recovery_ratio": [0.35, 0.4, 0.5, 0.6],
"tail_ratio_min": [1.0, 1.2, 1.5, 1.8, 2.0],
"tail_pct_min": [0.001, 0.002, 0.003],
"sl_pct": [0.015, 0.02, 0.025, 0.03, 0.04],
"tp_pct": [0.03, 0.04, 0.05, 0.06, 0.07, 0.08],
"shoulder_cut_pct": [0.02, 0.03, 0.04],
"ma20_max_above": [3.0, 5.0],
"max_daily_change": [15.0, 20.0, 25.0],
"stop_atr_mult": [2.0, 2.5, 3.0],
"target_atr_mult": [5.0, 6.0, 8.0, 10.0],
"max_loss_krw": [100000, 200000, 300000],
"min_drop_pct_for_loss_cut": [0.01, 0.015, 0.02, 0.025],
},
"massive": {
"min_drop_rate": [0.015, 0.02, 0.025, 0.03, 0.04, 0.05],
"min_recovery_ratio": [0.35, 0.4, 0.45, 0.5, 0.6],
"tail_ratio_min": [1.0, 1.2, 1.5, 1.8, 2.0],
"tail_pct_min": [0.001, 0.002, 0.003, 0.005],
"sl_pct": [0.015, 0.02, 0.025, 0.03, 0.04],
"tp_pct": [0.03, 0.04, 0.05, 0.06, 0.07, 0.10],
"shoulder_cut_pct": [0.02, 0.025, 0.03, 0.04],
"ma20_max_above": [2.0, 3.0, 5.0],
"max_daily_change": [15.0, 20.0, 25.0],
"stop_atr_mult": [2.0, 2.5, 3.0],
"target_atr_mult": [5.0, 7.0, 9.0],
"risk_pct": [0.005, 0.01, 0.02],
"kelly_mult": [0.15, 0.25, 0.5],
"max_loss_krw": [200000, 300000],
"min_drop_pct_for_loss_cut": [0.01, 0.015, 0.02],
}
}
def evaluate_param_chunk(
param_chunk: List[Dict[str, Any]],
base_params: Dict[str, Any],
candles_by_code: Dict[str, List[Dict]],
fee_rate: float,
sell_tax: float,
min_trades: int,
min_win_rate: float,
top_n: int,
universe_by_slot: Optional[Dict[str, List[str]]] = None,
) -> List[Tuple[float, Dict]]:
"""
워커 프로세스에서 실행될 백테스트 평가 함수.
universe_by_slot이 있으면 유니버스 히스토리(5분마다 후보)만 매수 검사.
"""
local_heap = []
for combo in param_chunk:
test_params = dict(base_params)
test_params.update(combo)
trades = te.run_tail_backtest(candles_by_code, test_params, universe_by_slot=universe_by_slot)
total_trades = len(trades)
if total_trades < min_trades:
continue
wins, losses, total_pnl = 0, 0, 0.0
for t in trades:
qty = t.get("qty", 1)
fee = (t["entry"] + t["exit"]) * qty * fee_rate
tax = t["exit"] * qty * sell_tax
pnl = (t["exit"] - t["entry"]) * qty - fee - tax
total_pnl += pnl
if pnl > 0: wins += 1
else: losses += 1
win_rate = (wins / total_trades) * 100 if total_trades > 0 else 0
result_pkg = {
"params": combo,
"total_trades": total_trades,
"win_rate": round(win_rate, 2),
"total_pnl": int(total_pnl),
"wins": wins,
"losses": losses
}
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 run_search(start: str, end: str, mode: str, top_n: int, min_trades: int, min_win_rate: float, sort_by: str = "pnl", use_fallback_universe: bool = False) -> bool:
"""탐색 실행. 결과가 있어서 JSON 저장까지 했으면 True, 조건 만족 조합 없이 조기 return 시 False."""
db = TradeDB()
try:
# 1. Base Environment Parameters 로드
base_params = te.get_tail_defaults_from_db(db)
row = db.conn.execute("SELECT * FROM env_config ORDER BY id DESC LIMIT 1").fetchone()
if row:
r = dict(row)
fee_rate = float(r.get("FEE_RATE_PCT") or 0.015) / 100
sell_tax = float(r.get("SELL_TAX_RATE_PCT") or 0.18) / 100
base_params["capital"] = float(r.get("BACKTEST_CAPITAL") or 10000000.0)
else:
fee_rate = 0.015 / 100
sell_tax = 0.18 / 100
base_params["capital"] = 10000000.0
# 2. 캔들 데이터 로드 (1번만 로드하여 멀티프로세스 워커에 전달)
start_key = start.replace("-", "") + "0000"
end_key = end.replace("-", "") + "2359"
logger.info(f"📅 데이터 로드: {start} ~ {end}")
codes_raw = db.conn.execute(
"SELECT DISTINCT code FROM ws_candles WHERE timeframe=3 "
"AND candle_time >= %s AND candle_time <= %s",
[start_key, end_key]
).fetchall()
codes = [r["code"] for r in codes_raw]
candles_by_code = {}
total_candles = 0
rsi_period = int(base_params.get("rsi_period", 14))
for code in codes:
rows = db.conn.execute(
"SELECT candle_time, open, high, low, close, volume "
"FROM ws_candles WHERE timeframe=3 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
candles_by_code[code] = [dict(r) for r in rows]
total_candles += len(rows)
if not candles_by_code:
logger.info("❌ 백테스트할 데이터가 없습니다.")
return False
logger.info(f"📦 종목 수: {len(candles_by_code)}개 | 총 캔들 수: {total_candles:,}")
# 2-2. 유니버스: 파람서치 시 조합별 거래 수가 너무 적으면 --fallback-universe 로 전체 종목 사용
# 신봇 기준: TradeDBExt.get_universe_by_candle_time("SHORT", ...) 로
# 초단위 event_time 이력을 1분 캔들 시각 키로 리샘플링해서 로드.
start_ymd = start_key[:8]
end_ymd = end_key[:8]
universe_by_slot = None
if use_fallback_universe:
print("📌 [유니버스] --fallback-universe: 저장 이력 무시 → 전체 종목 기준 (조합별 거래 수 확대)")
else:
try:
from kis_trader.database.db_manager import get_db as _get_ext_db
_ext = _get_ext_db()
history = _ext.get_universe_by_candle_time(
strategy_id="SHORT",
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}종목 (SHORT)"
)
print(
"📌 [유니버스] 이력만 쓰면 매수 기회가 적어 거래 0~1건 나올 수 있음. "
"조합 많을 때는 --fallback-universe 권장."
)
else:
print("📌 [유니버스] 저장 이력 없음 → 전체 종목 기준 (시간 제한 없음)")
except Exception as e:
logger.debug("유니버스 이력 조회 스킵: %s", e)
print("📌 [유니버스] 저장 이력 조회 실패 → 전체 종목 기준")
# 엔진에 슬롯 단위 주입: 신봇 이력 → 1분봉, 이력 없음 → universe_by_slot=None 이므로 무의미.
# base_params 에 명시해 두면 엔진 _slot_key 가 정확히 1분 키로 매칭.
base_params["scan_interval_min"] = 1
# 3. Grid 조합 생성
grid = PARAM_GRIDS.get(mode)
if not grid:
logger.error(f"❌ 알 수 없는 모드: {mode}")
return False
keys = list(grid.keys())
values = list(grid.values())
combos = list(itertools.product(*values))
total_combos = len(combos)
logger.info(f"🔍 탐색 모드: {mode.upper()} | 총 조합 수: {total_combos:,}")
logger.info(f"📌 1위 정렬 기준: {'총손익 최대 (수익 나는 조합 우선)' if sort_by == 'pnl' else '승률 최대'}")
dict_combos = [dict(zip(keys, combo)) for combo in combos]
# 4. 멀티프로세싱을 위한 청크 분할 (메모리 및 부하 분산)
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_combos // (max_workers * 4)))
chunks = [dict_combos[i:i + chunk_size] for i in range(0, len(dict_combos), chunk_size)]
logger.info(f"⚙️ 멀티프로세싱 시작 (코어: {n_cpu}, 워커: {max_workers} ≈ 80%%) | 청크: {len(chunks):,}")
start_time = time.time()
global_heap = []
# initializer=_worker_init 로 워커에 PR_SET_PDEATHSIG 설정 (부모 죽으면 자동 종료)
with ProcessPoolExecutor(max_workers=max_workers, initializer=_worker_init) as executor:
futures = {
executor.submit(
evaluate_param_chunk, chunk, base_params, candles_by_code,
fee_rate, sell_tax, min_trades, min_win_rate, top_n, universe_by_slot
): chunk for chunk in chunks
}
logger.info(f"⏳ 청크 처리 중… (청크당 최대 {chunk_size:,}개 조합, 완료되는 대로 진행률·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
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
# 지금까지 걸린 총 시간을 완료된 청크 개수로 나누어 청크당 평균 시간 도출
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:
logger.info(line)
if use_carriage_return:
print(flush=True) # 줄바꿈으로 진행률 줄 마무리
elapsed = time.time() - start_time
# 5. 결과 정렬 및 출력
if not global_heap:
logger.info("⚠️ 조건을 만족하는 조합이 없습니다. (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 필요
best_results = [heapq.heappop(global_heap)[3] for _ in range(len(global_heap))]
if sort_by == "win_rate":
best_results.reverse()
# sl_pct(손절%)별 상위 보장 → 한 값만 상위 독점 방지, 동점이면 손절 낮은 쪽(보수적) 1위
if "sl_pct" in keys and best_results:
sl_vals = sorted(set(r["params"]["sl_pct"] for r in best_results))
per_sl = max(1, top_n // len(sl_vals))
by_sl = {}
for r in best_results:
v = r["params"]["sl_pct"]
if v not in by_sl:
by_sl[v] = []
if len(by_sl[v]) < per_sl:
by_sl[v].append(r)
best_results = []
for v in sl_vals:
best_results.extend(by_sl.get(v, []))
best_results.sort(key=lambda r: (-r["total_pnl"], r["params"]["sl_pct"], -r["win_rate"]))
logger.info(f"✅ 손절(sl_pct)별 상위 {per_sl}개씩 보장 → {len(best_results)}건 (동점 시 손절 낮은 쪽 1위)")
filtered = [r for r in best_results if r["win_rate"] >= min_win_rate]
if filtered:
best_results = filtered
order_msg = "수익→승률 순" if sort_by == "pnl" else "승률→수익 순"
logger.info(f"✅ 승률 {min_win_rate}% 이상 {len(best_results)}건 중 {order_msg} 상위 사용")
else:
logger.info(f"⚠️ 승률 {min_win_rate}% 이상 없음 → 차악(상위) 적용")
# 손익 마이너스인 조합 제외 (수익 나는 것만 표시·저장)
profitable = [r for r in best_results if r["total_pnl"] > 0]
if profitable:
best_results = profitable
logger.info(f"✅ 총손익 플러스만 사용: {len(best_results)}건 (손실 조합 제외)")
else:
logger.info(f"⚠️ 수익 나는 조합 없음 → 손실 최소 순으로 표시")
order_label = "수익" if sort_by == "pnl" else "승률"
hdr_keys = [k for k in keys if k in (best_results[0]["params"] if best_results else {})]
col_w = max((len(k) for k in hdr_keys), default=6) + 2
sep_w = len(hdr_keys) * (col_w + 2) + 50
print(f"\n✅ 탐색 완료! 총 소요 시간: {elapsed:.1f}")
print(f"\n{'='*min(sep_w, 100)}")
print(f" 🏆 꼬리잡기 {order_label} TOP {min(top_n, len(best_results))}")
print(f"{'='*min(sep_w, 100)}")
hdr = " ".join(f"{k:>{col_w}}" for k in hdr_keys)
print(f"{hdr} | {'손익(원)':>12} {'승률':>6} {'거래':>5} {'PF':>5}")
print("-" * min(sep_w + 10, 110))
for i, res in enumerate(best_results[:top_n]):
p = res["params"]
row = " ".join(f"{p.get(k, ''):>{col_w}.4g}" for k in hdr_keys)
pf = res.get("pf", 0) or 0
print(f"{row} | {res['total_pnl']:>+12,.0f} {res['win_rate']:>5.1f}% {res['total_trades']:>5} {pf:>5.2f}")
best = best_results[0]
bp = best["params"]
print(f"""
╔══════════════════════════════════════════╗
║ 🏆 1위 최적 파라미터 ║
╠══════════════════════════════════════════╣""")
for k in hdr_keys:
v = bp.get(k, "")
print(f"{k:<28s} : {str(v):>6}")
print(f"""╠══════════════════════════════════════════╣
║ 총 손익 : {best['total_pnl']:>+12,.0f} 원 ║
║ 승률 : {best['win_rate']:>6.1f}% ║
║ 총 거래 : {best['total_trades']:>5} 건 ║
║ Profit Factor : {best.get('pf', 0):>5.2f}
╚══════════════════════════════════════════╝""")
# 6. JSON 파일로 결과 저장
out_data = {
"mode": mode,
"start": start,
"end": end,
"min_trades": min_trades,
"min_win_rate": min_win_rate,
"tested_combos": total_combos,
"elapsed_sec": round(elapsed, 1),
"results": best_results[:top_n]
}
out_dir = _results_dir_for_write()
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
out_path = os.path.join(out_dir, f"tail_search_{mode}_{ts}.json")
with open(out_path, "w", encoding="utf-8") as f:
json.dump(out_data, f, indent=2, ensure_ascii=False)
out_file = _tail_result_path_for_write()
with open(out_file, "w", encoding="utf-8") as f:
json.dump(out_data, f, indent=2, ensure_ascii=False)
logger.info(f"\n💾 결과 저장: {out_path}")
logger.info(f"💾 적용용 복사: {out_file}")
return True
except Exception as e:
logger.error(f"❌ 탐색 중 오류 발생: {e}", exc_info=True)
return False
finally:
db.close()
def apply_from_json(apply_idx: int):
"""
저장된 JSON 결과에서 N위 조합을 추출하여 env_config에 즉시 반영
"""
out_file = _find_tail_result_json()
if not out_file:
logger.error(
"❌ 결과를 찾을 수 없습니다. 후보 경로:\n - " +
"\n - ".join(_tail_result_paths_for_read())
)
return
with open(out_file, "r", encoding="utf-8") as f:
data = json.load(f)
results = data.get("results", [])
if not results:
logger.error("❌ JSON에 저장된 결과가 없습니다.")
return
if apply_idx < 1 or apply_idx > len(results):
logger.error(f"❌ 유효하지 않은 순위입니다. (1~{len(results)} 사이 입력)")
return
target = results[apply_idx - 1]
if target.get("total_pnl", 0) <= 0:
logger.warning(f"⚠️ {apply_idx}번째 결과는 총손익 ≤ 0 (조건 미충족). DB 미적용. 기존 설정 유지.")
print("⚠️ 해당 순위는 총손익 ≤ 0 (조건 미충족). DB 미적용. 기존 설정 유지.")
return
p = target["params"]
env_map = {}
if "min_drop_rate" in p: env_map["MIN_DROP_RATE"] = str(p["min_drop_rate"])
if "min_recovery_ratio" in p: env_map["MIN_RECOVERY_RATIO_SHORT"] = str(p["min_recovery_ratio"])
if "tail_ratio_min" in p: env_map["TAIL_RATIO_MIN"] = str(p["tail_ratio_min"])
if "tail_pct_min" in p: env_map["TAIL_PCT_MIN"] = str(p["tail_pct_min"])
if "sl_pct" in p: env_map["STOP_LOSS_PCT"] = str(-abs(p["sl_pct"]))
if "tp_pct" in p: env_map["TAKE_PROFIT_PCT"] = str(p["tp_pct"])
if "shoulder_cut_pct" in p: env_map["SHOULDER_CUT_PCT"] = str(p["shoulder_cut_pct"])
if "rsi_threshold" in p: env_map["RSI_OVERHEAT_THRESHOLD"] = str(p["rsi_threshold"])
if "ma20_max_above" in p: env_map["MA20_MAX_ABOVE_PCT"] = str(p["ma20_max_above"])
if "max_daily_change" in p: env_map["MAX_DAILY_CHANGE_PCT"] = str(p["max_daily_change"])
if "stop_atr_mult" in p: env_map["STOP_ATR_MULTIPLIER_TAIL"] = str(p["stop_atr_mult"])
if "target_atr_mult" in p: env_map["TARGET_ATR_MULTIPLIER_TAIL"] = str(p["target_atr_mult"])
if "risk_pct" in p: env_map["RISK_PCT_PER_TRADE"] = str(p["risk_pct"])
if "kelly_mult" in p: env_map["KELLY_MULTIPLIER"] = str(p["kelly_mult"])
if "max_loss_krw" in p: env_map["MAX_LOSS_PER_TRADE_KRW"] = str(int(float(p["max_loss_krw"])))
if "min_drop_pct_for_loss_cut" in p:
v = float(p["min_drop_pct_for_loss_cut"])
env_map["MIN_DROP_PCT_FOR_LOSS_CUT"] = str(round(v * 100, 2)) if v < 1 else str(round(v, 2))
db = TradeDB()
try:
latest = db.get_latest_env()
snap = dict(latest["snapshot"]) if latest else {}
snap.update(env_map)
env_id = db.insert_env_snapshot(snap)
logger.info(f"\n🚀 [자동 반영 완료] {apply_idx}위 조합을 DB에 적용했습니다. (env_id: {env_id})")
logger.info(f"적용된 값: {json.dumps(env_map, indent=2)}")
logger.info("실매매 봇(kis_trader/main.py TailCatchStrategy)이 다음 루프부터 이 설정을 사용하여 매매를 시작합니다.")
finally:
db.close()
def apply_params_to_db(p: dict):
"""
params 딕셔너리를 DB env_config에 반영. (param_apply_ai에서 AI가 고른 조합 적용 시 호출)
"""
env_map = {}
if "min_drop_rate" in p: env_map["MIN_DROP_RATE"] = str(p["min_drop_rate"])
if "min_recovery_ratio" in p: env_map["MIN_RECOVERY_RATIO_SHORT"] = str(p["min_recovery_ratio"])
if "tail_ratio_min" in p: env_map["TAIL_RATIO_MIN"] = str(p["tail_ratio_min"])
if "tail_pct_min" in p: env_map["TAIL_PCT_MIN"] = str(p["tail_pct_min"])
if "sl_pct" in p: env_map["STOP_LOSS_PCT"] = str(-abs(p["sl_pct"]))
if "tp_pct" in p: env_map["TAKE_PROFIT_PCT"] = str(p["tp_pct"])
if "shoulder_cut_pct" in p: env_map["SHOULDER_CUT_PCT"] = str(p["shoulder_cut_pct"])
if "rsi_threshold" in p: env_map["RSI_OVERHEAT_THRESHOLD"] = str(p["rsi_threshold"])
if "ma20_max_above" in p: env_map["MA20_MAX_ABOVE_PCT"] = str(p["ma20_max_above"])
if "max_daily_change" in p: env_map["MAX_DAILY_CHANGE_PCT"] = str(p["max_daily_change"])
if "stop_atr_mult" in p: env_map["STOP_ATR_MULTIPLIER_TAIL"] = str(p["stop_atr_mult"])
if "target_atr_mult" in p: env_map["TARGET_ATR_MULTIPLIER_TAIL"] = str(p["target_atr_mult"])
if "risk_pct" in p: env_map["RISK_PCT_PER_TRADE"] = str(p["risk_pct"])
if "kelly_mult" in p: env_map["KELLY_MULTIPLIER"] = str(p["kelly_mult"])
if "max_loss_krw" in p: env_map["MAX_LOSS_PER_TRADE_KRW"] = str(int(float(p["max_loss_krw"])))
if "min_drop_pct_for_loss_cut" in p:
v = float(p["min_drop_pct_for_loss_cut"])
env_map["MIN_DROP_PCT_FOR_LOSS_CUT"] = str(round(v * 100, 2)) if v < 1 else str(round(v, 2))
db = TradeDB()
try:
latest = db.get_latest_env()
snap = dict(latest["snapshot"]) if latest else {}
snap.update(env_map)
db.insert_env_snapshot(snap)
finally:
db.close()
def main():
today = datetime.now().strftime("%Y-%m-%d")
week_ago = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d")
parser = argparse.ArgumentParser(description="꼬리잡기 V3 백테스트 파라미터 최적화 (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", "massive"],
help="탐색 모드 (massive는 수백만 조합이므로 장시간 소요)")
parser.add_argument("--top", default=5000, type=int, help="메모리에 유지·JSON 저장할 상위 N개 (기본 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="승률 하한 (%%). 이 이상만 출력")
parser.add_argument("--apply", nargs="?", const=1, type=int, default=None, metavar="N",
help="N번째 결과를 DB에 적용 (기본 1위). --from-file 과 함께 쓰면 재탐색 없이 즉시 적용")
parser.add_argument("--from-file", action="store_true", help="탐색 생략하고 기존 저장된 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="저장 이력 무시, 전체 종목으로 매수 후보 산정. 조합 많을 때 거래 수 확대용 (이력 쓰면 슬롯 적어서 0~1건만 나올 수 있음)")
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_tail()
return
if args.from_file and args.apply is not None:
apply_from_json(args.apply)
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(
args.start, args.end, args.mode, args.top,
args.min_trades, args.min_win_rate,
sort_by=args.sort_by, use_fallback_universe=args.fallback_universe,
)
except KeyboardInterrupt as e:
print(f"\n{e} — 미완료 결과 없이 종료합니다.", flush=True)
sys.exit(130)
if args.apply is not None:
if _find_tail_result_json():
apply_from_json(args.apply)
else:
logger.warning("결과가 없어 DB 적용을 건너뜁니다.")
# 탐색에서 조건 만족 조합이 있었을 때만 방금 저장된 JSON으로 AI 적용 (없으면 기존 설정 유지)
if args.apply_ai and had_results:
import param_apply_ai
param_apply_ai.apply_ai_tail()
elif args.apply_ai and not had_results:
print("📌 이번 탐색에서 조건 만족 조합 없음 → apply_ai 스킵. DB 미적용. 기존 설정 유지.")
if __name__ == "__main__":
main()