709 lines
35 KiB
Python
709 lines
35 KiB
Python
#!/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()
|