#!/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()
