브랜치 분리 방식: A / B / C

A 선택 시 커밋 메시지: 위 초안 OK / 수정 / 직접 작성
작업 시점: 지금 / 운영 데이터 1~2일 쌓고 / 주말
This commit is contained in:
2026-05-05 21:04:17 +09:00
parent c2b2b711e0
commit f61c471aac
58 changed files with 803502 additions and 1430 deletions

View File

@@ -0,0 +1,708 @@
#!/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()