#!/usr/bin/env python3
"""
backtest_web.py — 매매 성과 분석 & 백테스트 웹 대시보드
==========================================================
실행: python3 backtest_web.py
접속: http://localhost:5050

탭1. 실거래 분석   → trade_history 기반 실제 매매 결과
탭2. 스캘핑 백테스트 → ws_candles 1분봉 가격 재현(Price-Replay) 백테스트
탭3. 꼬리잡기 백테스트 → ws_candles 3분봉 기반, tail_engine 연동 (V3 방어 파라미터 지원)
"""

import sys, os, math, json, logging, threading, uuid
from datetime import datetime, timedelta
from typing import List, Dict, Optional, Tuple

sys.path.insert(0, os.path.dirname(__file__))
from database import TradeDB
import holding_bot as hb
import kis_holding_ver1 as hv1  # V1: RSI 3단계 분할매수 (횡보장 전략)

from flask import Flask, jsonify, request, render_template_string

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("backtest_web")

# TradeDB 초기화/종료 반복 로그 억제 (백테스트 루프에서 수백 번 찍히는 것 방지)
logging.getLogger("TradeDB").setLevel(logging.WARNING)
app = Flask(__name__)

# 60분봉 수집 백그라운드 job 상태 저장소
_min_fetch_jobs: Dict[str, Dict] = {}

# ────────────────────────────────────────────────────────────────────────────
# 헬퍼 함수
# ────────────────────────────────────────────────────────────────────────────

def _db() -> TradeDB:
    return TradeDB()


def _get_fee_defaults() -> dict:
    """
    DB env_config 에서 수수료/세금 기본값 로드.
    백테스트 URL 파라미터 기본값으로 사용 → DB에 값이 있으면 그걸 따름.
    """
    try:
        db = _db()
        row = db.conn.execute(
            "SELECT * FROM env_config ORDER BY id DESC LIMIT 1"
        ).fetchone()
        db.close()
        if row:
            r = dict(row)
            return {
                "fee_rate": float(r.get("FEE_RATE_PCT") or 0.015),
                "sell_tax": float(r.get("SELL_TAX_RATE_PCT") or 0.18),
            }
    except Exception:
        pass
    return {"fee_rate": 0.015, "sell_tax": 0.18}


def _compute_rsi_series(closes: list, period: int = 3) -> list:
    """RSI 시리즈 계산 (Wilder 스무딩)"""
    rsi_list = [None] * len(closes)
    if len(closes) < period + 1:
        return rsi_list
    deltas = [closes[i] - closes[i - 1] for i in range(1, len(closes))]
    gains = [max(d, 0) for d in deltas]
    losses = [max(-d, 0) for d in deltas]
    avg_gain = sum(gains[:period]) / period
    avg_loss = sum(losses[:period]) / period
    for i in range(period, len(closes)):
        idx = i - 1  # delta 배열 기준
        if i > period:
            avg_gain = (avg_gain * (period - 1) + gains[idx]) / period
            avg_loss = (avg_loss * (period - 1) + losses[idx]) / period
        rs = avg_gain / avg_loss if avg_loss > 0 else float('inf')
        rsi_val = 100 - (100 / (1 + rs)) if avg_loss > 0 else 100.0
        rsi_list[i] = rsi_val
    return rsi_list


# ────────────────────────────────────────────────────────────────────────────
# 가상거래에 종목명 부여 (stock_meta 조회, 실거래와 동일하게 표시)
# ────────────────────────────────────────────────────────────────────────────
def _enrich_trades_with_names(db, trades: list) -> None:
    """trades 리스트 내 각 거래에 'name' 필드 추가. code만 있으면 stock_meta에서 조회."""
    if not trades:
        return
    codes = list({t.get("code") for t in trades if t.get("code")})
    if not codes:
        return
    try:
        placeholders = ", ".join(["%s"] * len(codes))
        rows = db.conn.execute(
            "SELECT code, name FROM stock_meta WHERE code IN (" + placeholders + ")",
            codes,
        ).fetchall()
        code_to_name = {r["code"]: (r.get("name") or r["code"]) for r in rows}
    except Exception:
        code_to_name = {}
    for t in trades:
        c = t.get("code")
        t["name"] = code_to_name.get(c, c) if c else ""


# ────────────────────────────────────────────────────────────────────────────
# API: 실거래 분석
# ────────────────────────────────────────────────────────────────────────────

@app.route("/api/actual", methods=["GET"])
def api_actual():
    strategy = request.args.get("strategy", "SCALP_RSI_REVERSAL")
    start    = request.args.get("start", "")
    end      = request.args.get("end", "")

    db = _db()
    try:
        # ── 전략 ID prefix 매칭 ─────────────────────────────────────────
        # 과거 봇(kis_scalping_ver2.py 등): strategy='SCALP_RSI_REVERSAL'
        # 신봇(kis_trader/main.py):        strategy='SCALP'
        # 두 포맷을 모두 한 화면에 잡기 위해 prefix LIKE 로 조회한다.
        # (스캘핑 = 'SCALP%', 꼬리잡기 = 'SHORT%')
        s_upper = (strategy or "").upper().strip()
        if s_upper.startswith("SCALP"):
            prefix = "SCALP"
        elif s_upper.startswith("SHORT") or s_upper.startswith("TAIL"):
            prefix = "SHORT"
        else:
            prefix = s_upper or "SCALP"
        like_pattern = prefix + "%"

        params = [like_pattern]
        sql = "SELECT * FROM trade_history WHERE strategy LIKE %s"
        if start:
            sql += " AND sell_date >= %s"
            params.append(start + " 00:00:00")
        if end:
            sql += " AND sell_date <= %s"
            params.append(end + " 23:59:59")
        sql += " ORDER BY sell_date ASC"
        rows = db.conn.execute(sql, params).fetchall()
        trades = [dict(r) for r in rows]

        # 잔고 동기화 이슈로 생성된 0원 강제정리 레코드는 실거래 성과를 왜곡하므로 분석 집계에서 제외
        # (실제 체결 손익이 아니라 로컬-브로커 불일치 정리용 레코드)
        filtered_out = 0
        cleaned: List[Dict] = []
        for t in trades:
            reason = str(t.get("sell_reason") or "")
            try:
                sell_price = float(t.get("sell_price") or 0)
            except Exception:
                sell_price = 0.0
            is_forced_ghost = ("잔고없음(강제정리)" in reason) or ("잔고동기화(외부매도)" in reason)
            if is_forced_ghost and sell_price <= 0:
                filtered_out += 1
                continue
            cleaned.append(t)
        trades = cleaned

        # 날짜 직렬화
        for t in trades:
            for k in ("buy_date", "sell_date"):
                if t.get(k):
                    t[k] = str(t[k])

        # 누적 손익 계산
        equity = []
        cum_pnl = 0.0
        for t in trades:
            cum_pnl += float(t.get("realized_pnl") or 0)
            equity.append({
                "date": t["sell_date"][:10] if t.get("sell_date") else "",
                "cum_pnl": round(cum_pnl),
                "pnl": round(float(t.get("realized_pnl") or 0)),
            })

        # 요약 통계
        total   = len(trades)
        wins    = [t for t in trades if float(t.get("realized_pnl") or 0) > 0]
        losses  = [t for t in trades if float(t.get("realized_pnl") or 0) < 0]
        total_pnl = sum(float(t.get("realized_pnl") or 0) for t in trades)
        avg_hold  = (sum(float(t.get("hold_minutes") or 0) for t in trades) / total) if total else 0

        win_pnl  = sum(float(t.get("realized_pnl") or 0) for t in wins)
        loss_pnl = sum(float(t.get("realized_pnl") or 0) for t in losses)
        profit_factor = round(abs(win_pnl / loss_pnl), 2) if loss_pnl != 0 else 9999.0

        # 최대 낙폭(MDD)
        peak, mdd = 0.0, 0.0
        cum = 0.0
        for t in trades:
            cum += float(t.get("realized_pnl") or 0)
            if cum > peak:
                peak = cum
            dd = peak - cum
            if dd > mdd:
                mdd = dd

        # 매도 이유별 집계
        reasons: Dict[str, int] = {}
        for t in trades:
            r = t.get("sell_reason") or "기타"
            reasons[r] = reasons.get(r, 0) + 1

        # 일별 P&L
        daily: Dict[str, float] = {}
        for t in trades:
            day = (t.get("sell_date") or "")[:10]
            if day:
                daily[day] = daily.get(day, 0) + float(t.get("realized_pnl") or 0)
        daily_list = [{"date": d, "pnl": round(v)} for d, v in sorted(daily.items())]

        # 종목별 상위 손익
        code_pnl: Dict[str, float] = {}
        code_name: Dict[str, str] = {}
        for t in trades:
            c = t["code"]
            code_pnl[c] = code_pnl.get(c, 0) + float(t.get("realized_pnl") or 0)
            code_name[c] = t.get("name") or c
        top_codes = sorted(code_pnl.items(), key=lambda x: x[1], reverse=True)[:10]
        top_list  = [{"code": c, "name": code_name[c], "pnl": round(v)} for c, v in top_codes]

        return jsonify({
            "summary": {
                "total_trades": total,
                "win_trades":   len(wins),
                "loss_trades":  len(losses),
                "win_rate":     round(len(wins) / total * 100, 1) if total else 0,
                "total_pnl":    round(total_pnl),
                "avg_hold_min": round(avg_hold, 1),
                "profit_factor": round(profit_factor, 2),
                "max_drawdown": round(mdd),
            },
            "meta": {"filtered_forced_rows": filtered_out},
            "equity":      equity,
            "daily":       daily_list,
            "reasons":     reasons,
            "top_codes":   top_list,
            "trades":      trades[-200:],  # 최근 200건
        })
    finally:
        db.close()


# ────────────────────────────────────────────────────────────────────────────
# API: 스캘핑 가격 재현 백테스트 (ws_candles 기반)
# ────────────────────────────────────────────────────────────────────────────

def _t2dt(t: str) -> datetime:
    return datetime.strptime(t, "%Y%m%d%H%M")

import scalping_engine as se

@app.route("/api/backtest/scalping", methods=["GET"])
def api_backtest_scalping():
    # 기본값 = DB(엔진 단일 소스) → 백테스트/param_search/실매매 동일 값
    _def = se.get_scalping_defaults_from_db()
    start          = request.args.get("start", "")
    end            = request.args.get("end", "")
    rsi_period     = int(request.args.get("rsi_period", _def["rsi_period"]))
    rsi_oversold   = float(request.args.get("rsi_oversold", 25))
    rsi_overbought = float(request.args.get("rsi_overbought", 75))
    sl_pct         = float(request.args.get("sl_pct", 1.5)) / 100      
    tp_pct         = float(request.args.get("tp_pct", 1.5)) / 100      
    drop_rate      = float(request.args.get("drop_rate", 1.5)) / 100   
    slot_money     = float(request.args.get("slot_money", _def["slot_money"]))   
    _fee_rate      = request.args.get("fee_rate")
    fee_rate       = float(_fee_rate) / 100 if _fee_rate not in (None, "") else _def["fee_rate"]
    _sell_tax      = request.args.get("sell_tax")
    sell_tax       = float(_sell_tax) / 100 if _sell_tax not in (None, "") else _def["sell_tax"]
    _cooldown      = request.args.get("cooldown_min")
    cooldown_min   = float(_cooldown) if _cooldown not in (None, "") else _def["cooldown_min"]
    vol_mult       = float(request.args.get("vol_mult", _def["vol_mult"]))      
    _tr_trigger    = request.args.get("trail_trigger")
    trail_trigger  = float(_tr_trigger) / 100 if _tr_trigger not in (None, "") else _def["trail_trigger"]
    _tr_stop       = request.args.get("trail_stop")
    trail_stop     = float(_tr_stop) / 100 if _tr_stop not in (None, "") else _def["trail_stop"]
    _time_start    = request.args.get("time_start")
    time_start_hm  = int(_time_start) if _time_start not in (None, "") else _def["time_start_hm"]
    _time_end      = request.args.get("time_end")
    time_end_hm    = int(_time_end) if _time_end not in (None, "") else _def["time_end_hm"]
    max_daily      = int(request.args.get("max_daily", _def["max_daily"]))
    _use_defense   = request.args.get("use_defense_filters")
    if _use_defense in (None, ""):
        use_defense_filters = bool(_def.get("use_defense_filters", True))
    else:
        use_defense_filters = str(_use_defense).strip().lower() in ("1", "true", "y", "yes", "on")

    db = _db()
    try:
        start_key = (start.replace("-", "") + "0000") if start else "20260101"
        end_key   = (end.replace("-", "") + "2359")   if end   else "99991231"

        codes_raw = db.conn.execute(
            "SELECT DISTINCT code FROM ws_candles WHERE timeframe=1 "
            "AND candle_time >= %s AND candle_time <= %s ORDER BY code",
            [start_key, end_key]
        ).fetchall()
        codes = [r["code"] for r in codes_raw]

        codes_candles: Dict[str, List[Dict]] = {}
        for code in codes:
            rows = db.conn.execute(
                "SELECT candle_time, open, high, low, close, volume "
                "FROM ws_candles "
                "WHERE timeframe=1 AND code=%s "
                "AND candle_time >= %s AND candle_time <= %s "
                "AND is_confirmed=1 "
                "ORDER BY candle_time ASC",
                [code, start_key, end_key]
            ).fetchall()
            if len(rows) < rsi_period + 5:
                continue
            codes_candles[code] = [dict(r) for r in rows]

        # 방어로직: 쿼리 인자로 넘어오면 우선 사용 (웹 입력란), 없으면 DB 기본값
        _high_chase   = request.args.get("high_chase_thr")
        _max_daily_ch = request.args.get("max_daily_chg")
        _min_pr       = request.args.get("min_price")
        _max_loss     = request.args.get("max_loss_krw")
        _min_marg     = request.args.get("min_margin")
        high_chase_thr = float(_high_chase) if _high_chase not in (None, "") else _def.get("high_chase_thr", 0.96)
        max_daily_chg  = float(_max_daily_ch) if _max_daily_ch not in (None, "") else _def.get("max_daily_chg", 20.0)
        min_price      = float(_min_pr) if _min_pr not in (None, "") else _def.get("min_price", 1000.0)
        max_loss_krw   = int(float(_max_loss)) if _max_loss not in (None, "") else int(_def.get("max_loss_krw", 200000))
        # min_margin: 웹에서 % 단위(0.2 등)로 오면 0.002로 변환
        min_margin     = float(_min_marg) / 100 if _min_marg not in (None, "") else _def.get("min_margin", 0.002)
        params = {
            "rsi_period":    rsi_period,
            "rsi_oversold":  rsi_oversold,
            "rsi_overbought": rsi_overbought,
            "sl_pct":        sl_pct,
            "tp_pct":        tp_pct,
            "drop_rate":     drop_rate,
            "slot_money":    slot_money,
            "fee_rate":      fee_rate,
            "sell_tax":      sell_tax,
            "cooldown_min":  cooldown_min,
            "trail_trigger": trail_trigger,
            "trail_stop":    trail_stop,
            "time_start_hm": time_start_hm,
            "time_end_hm":   time_end_hm,
            "max_daily":     max_daily,
            "vol_mult":      vol_mult,
            "high_chase_thr": high_chase_thr,
            "max_daily_chg":  max_daily_chg,
            "min_price":      min_price,
            "max_loss_krw":   max_loss_krw,
            "min_drop_pct_for_loss_cut": _def.get("min_drop_pct_for_loss_cut", 0.015),
            "min_margin":     min_margin,
            "use_defense_filters": use_defense_filters,
            "scan_interval_min": 5,
        }
        # 유니버스: 저장된 이력(target_candidates_history) 있으면 그 슬롯만 사용(시간 맞춤). 없으면 1분봉 시뮬레이션
        universe_source = "fallback"
        universe_history_slots = 0
        universe_by_slot = None
        try:
            history = db.get_universe_history_for_backtest(start_key[:8], end_key[:8])
            if history:
                universe_by_slot = history  # 이력 있는 슬롯만 사용 → 이력 없는 시간대는 매수 후보 없음(거래 없음)
                universe_source = "history"
                universe_history_slots = len(history)
        except Exception:
            pass
        if universe_by_slot is None:
            _universe_top_n = int(request.args.get("universe_top_n") or os.environ.get("UPDATE_UNIVERSE_TOP_N", "20"))
            _universe_min_score = float(request.args.get("universe_min_score") or os.environ.get("UPDATE_UNIVERSE_MIN_SCORE", "4.0"))
            universe_by_slot = se.build_universe_simulation(
                codes_candles, top_n=_universe_top_n, min_score=_universe_min_score, scan_interval_min=5
            )
        all_virtual_trades = se.run_scalping_backtest(codes_candles, params, universe_by_slot=universe_by_slot)
        # ... 이하 로직 생략 (기존과 동일) ...

        # ─────────────────────────────────────────────────────────────────
        # 결과 집계
        # ─────────────────────────────────────────────────────────────────

        equity = []
        cum = 0.0
        for t in all_virtual_trades:
            cum += t["pnl"]
            equity.append({"date": t["sell_time"][:8], "cum_pnl": round(cum), "pnl": t["pnl"]})

        total     = len(all_virtual_trades)
        wins      = [t for t in all_virtual_trades if t["pnl"] > 0]
        losses    = [t for t in all_virtual_trades if t["pnl"] < 0]
        total_pnl = sum(t["pnl"] for t in all_virtual_trades)
        avg_hold  = (sum(t["hold_min"] for t in all_virtual_trades) / total) if total else 0
        win_pnl   = sum(t["pnl"] for t in wins)
        loss_pnl  = sum(t["pnl"] for t in losses)
        pf        = round(abs(win_pnl / loss_pnl), 2) if loss_pnl != 0 else 9999.0

        peak, mdd, cum = 0.0, 0.0, 0.0
        for t in all_virtual_trades:
            cum += t["pnl"]
            if cum > peak: peak = cum
            dd = peak - cum
            if dd > mdd: mdd = dd

        reasons: Dict[str, int] = {}
        for t in all_virtual_trades:
            reasons[t["sell_reason"]] = reasons.get(t["sell_reason"], 0) + 1

        daily: Dict[str, float] = {}
        for t in all_virtual_trades:
            d8 = t["sell_time"][:8]
            daily[d8] = daily.get(d8, 0) + t["pnl"]
        daily_list = [{"date": d[:4]+"-"+d[4:6]+"-"+d[6:], "pnl": round(v)}
                      for d, v in sorted(daily.items())]

        # 가상거래에도 종목명 표시 (실거래와 동일)
        trades_out = all_virtual_trades[-200:]
        _enrich_trades_with_names(db, trades_out)

        return jsonify({
            "params": {
                "rsi_period":    rsi_period,
                "rsi_oversold":  rsi_oversold,
                "rsi_overbought": rsi_overbought,
                "sl_pct":        sl_pct * 100,
                "tp_pct":        tp_pct * 100,
                "drop_rate":     drop_rate * 100,
                "slot_money":    slot_money,
                "cooldown_min":  cooldown_min,
                "vol_mult":      vol_mult,
                "trail_trigger": trail_trigger * 100,
                "trail_stop":    trail_stop * 100,
                "time_window":   f"{time_start_hm:04d}-{time_end_hm:04d}",
                "max_daily":     max_daily,
                "codes_analyzed": len(codes),
                "universe_source":       universe_source,
                "universe_history_slots": universe_history_slots,
            },
            "summary": {
                "total_trades":  total,
                "win_trades":    len(wins),
                "loss_trades":   len(losses),
                "win_rate":      round(len(wins) / total * 100, 1) if total else 0,
                "total_pnl":     round(total_pnl),
                "avg_hold_min":  round(avg_hold, 1),
                "profit_factor": round(pf, 2),
                "max_drawdown":  round(mdd),
            },
            "equity":  equity,
            "daily":   daily_list,
            "reasons": reasons,
            "trades":  trades_out,
        })
    finally:
        db.close()


# ────────────────────────────────────────────────────────────────────────────
# API: 꼬리잡기 가격 재현 백테스트 (ws_candles 3분봉 기반, tail_engine 공통 로직 사용)
# ────────────────────────────────────────────────────────────────────────────

try:
    import tail_engine as te
    _TAIL_ENGINE_AVAILABLE = True
except ImportError:
    _TAIL_ENGINE_AVAILABLE = False


def _get_tail_defaults_for_backtest():
    """꼬리잡기 백테스트 기본값: DB 단일 소스. 엔진 없으면 빈 dict."""
    if not _TAIL_ENGINE_AVAILABLE:
        return {}
    return te.get_tail_defaults_from_db()


@app.route("/api/backtest/tail", methods=["GET"])
def api_backtest_tail():
    """
    꼬리잡기 전략 가격 재현 백테스트.
    entry 조건: 당일 낙폭(drop_rate) + 회복률(recovery_ratio) + 망치봉 꼬리 + RSI
    exit  조건: 손절 / 익절 / 어깨 컷(trailing) / 장 마감 강제 청산
    [V3 통합]: 추가 방어 파라미터(MA20, ATR 배수, 피뢰침 등) 적용
    기본값 = DB(env_config) → tail_engine.get_tail_defaults_from_db(), 요청으로 덮어쓰기.
    """
    _def = _get_tail_defaults_for_backtest()
    start               = request.args.get("start", "")
    end                 = request.args.get("end", "")
    rsi_period          = int(  request.args.get("rsi_period",       _def.get("rsi_period", 14)))
    rsi_threshold       = float(request.args.get("rsi_threshold",      _def.get("rsi_threshold", 78)))
    min_drop_rate       = float(request.args.get("min_drop_rate",      _def.get("min_drop_rate", 0.03) * 100)) / 100
    min_recovery_ratio  = float(request.args.get("min_recovery_ratio", _def.get("min_recovery_ratio", 0.5) * 100)) / 100
    # max_rec_3m / high_chase_thr: 폼에서 80·96(퍼센트) 또는 0.8·0.96(비율) 전달 가능 → 엔진은 항상 비율(0~1)
    _max_rec_raw        = float(request.args.get("max_rec_3m",         _def.get("max_rec_3m", 0.8)))
    max_rec_3m          = _max_rec_raw if 0 < _max_rec_raw <= 1 else _max_rec_raw / 100
    tail_ratio_min      = float(request.args.get("tail_ratio_min",     _def.get("tail_ratio_min", 1.5)))
    tail_pct_min        = float(request.args.get("tail_pct_min",       _def.get("tail_pct_min", 0.003) * 100)) / 100
    sl_pct              = float(request.args.get("sl_pct",            _def.get("sl_pct", 0.03) * 100)) / 100
    tp_pct              = float(request.args.get("tp_pct",             _def.get("tp_pct", 0.05) * 100)) / 100
    shoulder_min_high   = float(request.args.get("shoulder_min_high",   _def.get("shoulder_min_high", 0.015) * 100)) / 100
    shoulder_cut_pct    = float(request.args.get("shoulder_cut_pct",   _def.get("shoulder_cut_pct", 0.03) * 100)) / 100
    _high_chase_raw     = float(request.args.get("high_chase_thr",     _def.get("high_chase_thr", 0.96)))
    high_chase_thr      = _high_chase_raw if 0 < _high_chase_raw <= 1 else _high_chase_raw / 100
    slot_money          = float(request.args.get("slot_money",  1_000_000))
    _fee_d              = _get_fee_defaults()
    fee_rate            = float(request.args.get("fee_rate",  _fee_d["fee_rate"])) / 100
    sell_tax            = float(request.args.get("sell_tax",  _fee_d["sell_tax"])) / 100
    cooldown_min        = int(  request.args.get("cooldown_min",        _def.get("cooldown_min", 15)))
    time_start_hm       = int(  request.args.get("time_start",         _def.get("time_start_hm", 930)))
    time_end_hm         = int(  request.args.get("time_end",           _def.get("time_end_hm", 1500)))
    max_daily           = int(  request.args.get("max_daily",           _def.get("max_daily", 3)))

    # V3 방어 파라미터 연동
    min_price           = float(request.args.get("min_price",           _def.get("min_price", 1000.0)))
    max_daily_change    = float(request.args.get("max_daily_change",    _def.get("max_daily_change", 20.0)))
    ma20_max_above      = float(request.args.get("ma20_max_above",      _def.get("ma20_max_above", 3.0)))
    stop_atr_mult       = float(request.args.get("stop_atr_mult",       _def.get("stop_atr_mult", 2.5)))
    target_atr_mult     = float(request.args.get("target_atr_mult",     _def.get("target_atr_mult", 8.0)))
    max_loss_krw        = int(request.args.get("max_loss_krw",          _def.get("max_loss_krw", 200000)))
    _min_drop_loss_arg  = request.args.get("min_drop_pct_for_loss_cut")
    min_drop_pct_for_loss_cut = _def.get("min_drop_pct_for_loss_cut", 0.015)
    if _min_drop_loss_arg not in (None, ""):
        v = float(_min_drop_loss_arg)
        min_drop_pct_for_loss_cut = v / 100.0 if v >= 1 else v
    risk_pct            = float(request.args.get("risk_pct",            _def.get("risk_pct", 0.01) * 100)) / 100
    kelly_mult          = float(request.args.get("kelly_mult",          _def.get("kelly_mult", 0.25)))
    min_hold_sec        = float(request.args.get("min_hold_sec",        _def.get("min_hold_sec", 30.0)))
    capital             = float(request.args.get("capital",             _def.get("capital", 100000000.0)))

    db = _db()
    try:
        start_key = (start.replace("-", "") + "0000") if start else "20260101"
        end_key   = (end.replace("-", "")   + "2359") if end   else "99991231"

        codes_raw = db.conn.execute(
            "SELECT DISTINCT code FROM ws_candles WHERE timeframe=3 "
            "AND candle_time >= %s AND candle_time <= %s ORDER BY code",
            [start_key, end_key]
        ).fetchall()
        codes = [r["code"] for r in codes_raw]

        use_engine = _TAIL_ENGINE_AVAILABLE and request.args.get("use_engine", "1") == "1"
        all_trades: List[Dict] = []
        universe_source = "fallback"
        universe_history_slots = 0

        if use_engine:
            # tail_engine 공통 로직 사용 (실매매 ver3과 동일 계산식)
            candles_by_code = {}
            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]

            params = {
                "min_drop_rate": min_drop_rate, "min_recovery_ratio": min_recovery_ratio,
                "max_rec_3m": max_rec_3m, "tail_ratio_min": tail_ratio_min, "tail_pct_min": tail_pct_min,
                "sl_pct": sl_pct, "tp_pct": tp_pct,
                "shoulder_min_high": shoulder_min_high, "shoulder_cut_pct": shoulder_cut_pct,
                "rsi_period": rsi_period, "rsi_threshold": rsi_threshold, "high_chase_thr": high_chase_thr,
                "time_start_hm": time_start_hm, "time_end_hm": time_end_hm,
                "cooldown_min": cooldown_min, "max_daily": max_daily,
                "min_price": min_price, "max_daily_change": max_daily_change, "ma20_max_above": ma20_max_above,
                "stop_atr_mult": stop_atr_mult, "target_atr_mult": target_atr_mult, "max_loss_krw": max_loss_krw,
                "min_drop_pct_for_loss_cut": min_drop_pct_for_loss_cut,
                "risk_pct": risk_pct, "kelly_mult": kelly_mult, "min_hold_sec": min_hold_sec, "capital": capital,
                "scan_interval_min": 5,
            }
            # 유니버스: 저장된 이력(target_candidates_history) 우선, 없으면 전체 종목
            universe_by_slot = None
            try:
                history = db.get_universe_history_for_backtest(start_key[:8], end_key[:8])
                if history:
                    universe_by_slot = history
                    universe_source = "history"
                    universe_history_slots = len(history)
            except Exception:
                pass
            all_trades = te.run_tail_backtest(candles_by_code, params, universe_by_slot=universe_by_slot)
            for t in all_trades:
                # 엔진이 동적 수량(qty)을 내려주면 사용하고, 없으면 기존 고정 투자금 적용 (하위 호환성)
                qty = t.get("qty")
                if qty is None:
                    qty = max(1, int(slot_money / t["entry"]))
                fee = (t["entry"] + t["exit"]) * qty * fee_rate
                tax = t["exit"] * qty * sell_tax
                t["pnl"] = round((t["exit"] - t["entry"]) * qty - fee - tax)
                t["hold_min"] = round((_t2dt(t["exit_time"]) - _t2dt(t["entry_time"])).total_seconds() / 60, 1)
        else:
            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 = [dict(r) for r in rows]
                closes  = [float(c["close"]) for c in candles]
                rsis    = _compute_rsi_series(closes, rsi_period)

                position = None
                last_exit_dt: Dict[str, datetime] = {}
                daily_cnt:    Dict[str, int]       = {}

                # look-ahead 없는 당일 누적 OHLC
                cur_day       = None
                running_open  = 0.0
                running_high  = 0.0
                running_low   = 0.0

                for i in range(rsi_period + 1, len(candles)):
                    c   = candles[i]
                    day = c["candle_time"][:8]
                    hm  = int(c["candle_time"][8:12])
                    op  = float(c["open"])
                    hi  = float(c["high"])
                    lo  = float(c["low"])
                    cl  = float(c["close"])

                    # ── 당일 누적 OHLC 갱신 (선행 편향 없음) ─────────────────
                    if day != cur_day:
                        cur_day      = day
                        running_open = op
                        running_high = hi
                        running_low  = lo if lo > 0 else hi
                    else:
                        running_high = max(running_high, hi)
                        if lo > 0:
                            running_low = min(running_low, lo)

                    # ── 마지막 봉 여부 ────────────────────────────────────────
                    is_eod = (i == len(candles) - 1) or (candles[i + 1]["candle_time"][:8] != day)

                    # ─────────────────────────────────────────────────────────
                    # 포지션 보유 중: 청산 체크
                    # ─────────────────────────────────────────────────────────
                    if position is not None:
                        max_p = max(position["max_price"], hi)
                        position["max_price"] = max_p

                        reason     = None
                        exit_price = cl

                        # 손절: 저가가 손절선 이하
                        if lo > 0 and lo <= position["stop"]:
                            reason     = "손절"
                            exit_price = position["stop"]
                        # 익절: 고가가 익절선 이상
                        elif hi >= position["target"]:
                            reason     = "익절"
                            exit_price = position["target"]
                        # 어깨 컷(trailing): 충분히 오른 뒤 고점에서 일정 이상 하락
                        elif (max_p >= position["entry_price"] * (1 + shoulder_min_high)
                              and cl <= max_p * (1 - shoulder_cut_pct)):
                            reason     = "어깨컷"
                            exit_price = cl
                        # 장 마감 강제 청산
                        elif is_eod:
                            reason     = "장마감"
                            exit_price = cl

                        if reason:
                            ep   = position["entry_price"]
                            qty  = max(1, int(slot_money / ep))
                            fee  = (ep + exit_price) * qty * fee_rate
                            tax  = exit_price * qty * sell_tax
                            pnl  = (exit_price - ep) * qty - fee - tax
                            hold = round((
                                _t2dt(c["candle_time"]) -
                                _t2dt(position["entry_time"])
                            ).total_seconds() / 60, 1)

                            all_trades.append({
                                "code":       code,
                                "entry_time": position["entry_time"],
                                "exit_time":  c["candle_time"],
                                "entry":      round(ep),
                                "exit":       round(exit_price),
                                "pnl":        round(pnl),
                                "reason":     reason,
                                "hold_min":   hold,
                            })
                            last_exit_dt[day] = _t2dt(c["candle_time"])
                            daily_cnt[day]    = daily_cnt.get(day, 0) + 1
                            position = None
                        continue  # 다음 봉으로

                    # ─────────────────────────────────────────────────────────
                    # 포지션 없음: 매수 조건 체크
                    # ─────────────────────────────────────────────────────────
                    if cl <= 0 or running_open <= 0:
                        continue
                    if hm < time_start_hm or hm > time_end_hm:
                        continue
                    if daily_cnt.get(day, 0) >= max_daily:
                        continue

                    # 쿨다운: 마지막 청산 후 N분 이내 재진입 금지
                    if day in last_exit_dt:
                        elapsed = (_t2dt(c["candle_time"]) - last_exit_dt[day]).total_seconds() / 60
                        if elapsed < cooldown_min:
                            continue

                    # ── 1. 당일 낙폭 ─────────────────────────────────────────
                    drop = (running_open - running_low) / running_open
                    if drop < min_drop_rate:
                        continue

                    # ── 2. 당일 회복률 ─────────────────────────────────────
                    day_range = running_high - running_low
                    rec_day   = (cl - running_low) / day_range if day_range > 0 else 0
                    if rec_day < min_recovery_ratio:
                        continue

                    # ── 3. 망치봉 꼬리 비율 계산 ────────────────────────────
                    body_top    = max(op, cl)
                    body_bot    = min(op, cl)
                    body_len    = body_top - body_bot if body_top > body_bot else 1.0
                    tail_len    = body_bot - lo if lo > 0 else 0.0
                    # 꼬리 없는 봉이면 이전 봉에서 재탐색 (최대 3봉 전)
                    if tail_len <= 0:
                        for j in range(i - 1, max(i - 4, rsi_period), -1):
                            prev = candles[j]
                            o2, h2, l2, c2 = float(prev["open"]), float(prev["high"]), float(prev["low"]), float(prev["close"])
                            if l2 <= 0:
                                continue
                            bt2, bb2 = max(o2, c2), min(o2, c2)
                            bl2 = bt2 - bb2 if bt2 > bb2 else 1.0
                            tl2 = bb2 - l2
                            if tl2 > 0:
                                tail_len = tl2
                                body_len = bl2
                                lo = l2
                                break

                    tail_ratio = tail_len / body_len
                    tail_pct   = tail_len / lo if lo > 0 and tail_len > 0 else 0.0

                    if tail_ratio < tail_ratio_min or tail_pct < tail_pct_min:
                        continue

                    # ── 4. 3분봉 내 회복 위치 (무릎~어깨) ──────────────────
                    c_range    = float(c["high"]) - float(c["low"])
                    rec_3m     = (cl - float(c["low"])) / c_range if c_range > 0 else 0
                    if not (min_recovery_ratio <= rec_3m <= max_rec_3m):
                        continue

                    # ── 5. RSI 과열 방지 ────────────────────────────────────
                    rsi_val = rsis[i]
                    if rsi_val is None or rsi_val >= rsi_threshold:
                        continue

                    # ── 6. 피뢰침 방지: 고점 근접 추격 금지 ────────────────
                    if cl >= running_high * high_chase_thr:
                        continue

                    # ── 매수 실행: 다음 봉 시가 진입 ───────────────────────
                    if i + 1 >= len(candles):
                        continue
                    next_c = candles[i + 1]
                    if next_c["candle_time"][:8] != day:
                        continue  # 장 마감 직전 봉이면 다음날 시가 = 갭위험 → skip

                    entry_price = float(next_c["open"])
                    if entry_price <= 0:
                        entry_price = cl

                    position = {
                        "entry_price": entry_price,
                        "entry_time":  next_c["candle_time"],
                        "stop":        entry_price * (1 - sl_pct),
                        "target":      entry_price * (1 + tp_pct),
                        "max_price":   entry_price,
                    }
                    # 진입 봉을 이미 처리했으므로 다음 인덱스로 이동
                    i += 1

        # ── 통계 집계 ──────────────────────────────────────────────────
        total  = len(all_trades)
        wins   = [t for t in all_trades if t["pnl"] > 0]
        losses = [t for t in all_trades if t["pnl"] < 0]
        total_pnl = sum(t["pnl"] for t in all_trades)
        avg_hold  = (sum(t["hold_min"] for t in all_trades) / total) if total else 0

        win_pnl  = sum(t["pnl"] for t in wins)
        loss_pnl = sum(t["pnl"] for t in losses)
        pf = round(abs(win_pnl / loss_pnl), 2) if loss_pnl != 0 else 9999.0

        # MDD
        peak, mdd, cum = 0.0, 0.0, 0.0
        equity, daily_map = [], {}
        for t in sorted(all_trades, key=lambda x: x["exit_time"]):
            cum += t["pnl"]
            if cum > peak:
                peak = cum
            dd = peak - cum
            if dd > mdd:
                mdd = dd
            day = t["exit_time"][:8]
            equity.append({"date": f"{day[:4]}-{day[4:6]}-{day[6:]}", "cum_pnl": round(cum)})
            daily_map[day] = daily_map.get(day, 0) + t["pnl"]

        daily_list = [{"date": f"{d[:4]}-{d[4:6]}-{d[6:]}", "pnl": round(v)}
                      for d, v in sorted(daily_map.items())]

        reasons: Dict[str, int] = {}
        for t in all_trades:
            reasons[t["reason"]] = reasons.get(t["reason"], 0) + 1

        tail_trades_out = all_trades[-200:]
        _enrich_trades_with_names(db, tail_trades_out)

        return jsonify({
            "params": {
                "start": start, "end": end,
                "rsi_period": rsi_period, "rsi_threshold": rsi_threshold,
                "min_drop_rate": min_drop_rate * 100,
                "min_recovery_ratio": min_recovery_ratio * 100,
                "max_rec_3m": max_rec_3m * 100,
                "tail_ratio_min": tail_ratio_min,
                "tail_pct_min": tail_pct_min * 100,
                "sl_pct": sl_pct * 100,
                "tp_pct": tp_pct * 100,
                "shoulder_min_high": shoulder_min_high * 100,
                "shoulder_cut_pct": shoulder_cut_pct * 100,
                "slot_money": slot_money,
                "cooldown_min": cooldown_min,
                "time_start_hm": time_start_hm,
                "time_end_hm": time_end_hm,
                "time_window": f"{time_start_hm:04d}-{time_end_hm:04d}",
                "max_daily": max_daily,
                "min_price": min_price,
                "max_daily_change": max_daily_change,
                "ma20_max_above": ma20_max_above,
                "stop_atr_mult": stop_atr_mult,
                "target_atr_mult": target_atr_mult,
                "max_loss_krw": max_loss_krw,
                "risk_pct": risk_pct * 100,
                "kelly_mult": kelly_mult,
                "min_hold_sec": min_hold_sec,
                "capital": capital,
                "codes_analyzed": len(codes),
                "universe_source": universe_source,
                "universe_history_slots": universe_history_slots,
            },
            "summary": {
                "total_trades":  total,
                "win_trades":    len(wins),
                "loss_trades":   len(losses),
                "win_rate":      round(len(wins) / total * 100, 1) if total else 0,
                "total_pnl":     round(total_pnl),
                "avg_hold_min":  round(avg_hold, 1),
                "profit_factor": round(pf, 2),
                "max_drawdown":  round(mdd),
            },
            "equity":  equity,
            "daily":   daily_list,
            "reasons": reasons,
            "trades":  tail_trades_out,
        })
    finally:
        db.close()


# ────────────────────────────────────────────────────────────────────────────
# API: 홀딩 전략 — 관심종목 + 종목별 파라미터 + 캔들 수집 + 백테스트 + 파라미터 탐색
# ────────────────────────────────────────────────────────────────────────────

def _holding_db() -> TradeDB:
    db = _db()
    hb.ensure_holding_tables(db)
    return db


@app.route("/api/holding/stocks", methods=["GET"])
def api_holding_stocks():
    """관심종목 목록 + 종목별 현재 파라미터 + 보유 봉수 반환"""
    db = _holding_db()
    try:
        items = hb.load_watchlist()
        result = []
        for item in items:
            code = item["code"]
            cfg  = hb.get_stock_config(db, code)
            cfg["name"] = item.get("name", cfg.get("name", ""))
            # 보유 봉수
            row = db.conn.execute(
                "SELECT COUNT(*) as cnt, MIN(candle_date) as mn, MAX(candle_date) as mx "
                "FROM holding_candles WHERE code=%s", [code]
            ).fetchone()
            cfg["candle_count"] = int(row["cnt"]) if row else 0
            cfg["candle_min"]   = str(row["mn"]) if row and row["mn"] else ""
            cfg["candle_max"]   = str(row["mx"]) if row and row["mx"] else ""
            # 60분봉 현황 추가
            ms = hb.get_min_candle_stats(db, code, tf_min=60)
            cfg["min60_count"] = ms["count"]
            cfg["min60_min"]   = ms["min"]
            cfg["min60_max"]   = ms["max"]
            result.append(cfg)
        return jsonify(result)
    finally:
        db.close()


@app.route("/api/holding/config/<code>", methods=["GET", "POST"])
def api_holding_config(code):
    """GET: 종목 파라미터 조회 | POST: 파라미터 저장"""
    db = _holding_db()
    try:
        if request.method == "GET":
            cfg = hb.get_stock_config(db, code)
            return jsonify(cfg)
        else:
            body = request.get_json(force=True) or {}
            name = body.pop("name", "")
            hb.set_stock_config(db, code, name, body)
            return jsonify({"ok": True})
    finally:
        db.close()


@app.route("/api/holding/candles/fetch", methods=["POST"])
def api_holding_fetch_candles():
    """종목 일봉 캔들 KIS API로 수집 후 DB 저장"""
    body       = request.get_json(force=True) or {}
    code       = body.get("code", "")
    start_date = body.get("start", "2023-01-01")
    end_date   = body.get("end",   datetime.now().strftime("%Y-%m-%d"))

    if not code:
        return jsonify({"error": "code 필수"}), 400

    db = _holding_db()
    try:
        app_key, app_secret, base_url, mock = hb._get_kis_token(db)
        rows  = hb.fetch_daily_ohlcv(code, start_date, end_date, app_key, app_secret, base_url, mock=mock)
        saved = hb.store_candles(db, code, rows)
        return jsonify({"ok": True, "fetched": len(rows), "saved": saved})
    except Exception as e:
        logger.error(f"캔들 수집 오류 ({code}): {e}")
        return jsonify({"error": str(e)}), 500
    finally:
        db.close()


@app.route("/api/holding/backtest", methods=["GET"])
def api_holding_backtest():
    """홀딩 전략 백테스트 (종목별 파라미터 사용 or 요청 파라미터 오버라이드)"""
    code       = request.args.get("code", "")
    start_date = request.args.get("start", "2023-01-01")
    end_date   = request.args.get("end",   datetime.now().strftime("%Y-%m-%d"))

    if not code:
        return jsonify({"error": "code 필수"}), 400

    db = _holding_db()
    try:
        candles = hb.get_stored_candles(db, code, start_date, end_date)
        if len(candles) < 20:
            # 친절한 에러: DB 전체 보유 봉 수와 기간도 함께 안내
            all_candles = hb.get_stored_candles(db, code)
            if not all_candles:
                hint = "📥 먼저 [캔들 수집] 버튼으로 데이터를 수집하세요."
            else:
                first = str(all_candles[0]["candle_date"])[:10]
                last  = str(all_candles[-1]["candle_date"])[:10]
                hint  = (
                    f"DB에 {len(all_candles)}봉 있음 ({first} ~ {last})\n"
                    f"👉 백테스트 시작일을 '{first}' 이후로 설정하세요."
                )
            return jsonify({
                "error": f"봉 부족: {len(candles)}개 (최소 20개 필요)\n{hint}"
            }), 400

        # 요청 파라미터로 DB 설정 오버라이드 가능
        cfg = hb.get_stock_config(db, code)
        for ck in hb.DEFAULT_STOCK_CONFIG:
            v = request.args.get(ck)
            if v is not None:
                cfg[ck] = float(v)

        result = hb.run_backtest(candles, cfg)
        result["code"]         = code
        result["candle_count"] = len(candles)
        result["params"]       = {k: cfg[k] for k in hb.DEFAULT_STOCK_CONFIG}
        return jsonify(result)
    finally:
        db.close()


@app.route("/api/holding/v1/backtest", methods=["GET"])
def api_holding_v1_backtest():
    """홀딩 V1 (RSI 분할매수) 백테스트"""
    code       = request.args.get("code", "")
    start_date = request.args.get("start", "")
    end_date   = request.args.get("end",   datetime.now().strftime("%Y-%m-%d"))
    if not code:
        return jsonify({"error": "code 필수"}), 400

    # 카드 UI 파라미터 수집 (DEFAULT_V1_CONFIG 키 기준)
    cfg = {}
    for key in hv1.DEFAULT_V1_CONFIG.keys():
        val = request.args.get(key)
        if val is not None:
            try:
                cfg[key] = float(val)
            except (ValueError, TypeError):
                pass

    db = _holding_db()
    try:
        candles = hb.get_stored_candles(db, code, start_date, end_date)
        if len(candles) < 10:
            return jsonify({"error": f"봉 부족: {len(candles)}개"}), 400
        res = hv1.run_backtest_v1(candles, cfg)
        return jsonify(res)
    finally:
        db.close()


@app.route("/api/holding/v1/param_search", methods=["GET"])
def api_holding_v1_param_search():
    """홀딩 V1 (RSI 분할매수) 파라미터 Grid Search"""
    code       = request.args.get("code", "")
    start_date = request.args.get("start", "")
    end_date   = request.args.get("end",   datetime.now().strftime("%Y-%m-%d"))
    min_trades = int(request.args.get("min_trades", 2))
    if not code:
        return jsonify({"error": "code 필수"}), 400

    # 카드 UI 파라미터를 base_cfg로 (그리드 외 파라미터 고정)
    base_cfg = {}
    for key in hv1.DEFAULT_V1_CONFIG.keys():
        val = request.args.get(key)
        if val is not None:
            try:
                base_cfg[key] = float(val)
            except (ValueError, TypeError):
                pass

    db = _holding_db()
    try:
        candles = hb.get_stored_candles(db, code, start_date, end_date)
        if len(candles) < 20:
            return jsonify({"error": f"봉 부족: {len(candles)}개"}), 400
        results = hv1.run_param_search_v1(candles, min_trades=min_trades,
                                           base_cfg=base_cfg if base_cfg else None)
        return jsonify({"code": code, "top": results[:30]})
    finally:
        db.close()


@app.route("/api/holding/param_search", methods=["GET"])
def api_holding_param_search():
    """홀딩 전략 파라미터 Grid Search (단일 종목)"""
    code       = request.args.get("code", "")
    start_date = request.args.get("start", "2023-01-01")
    end_date   = request.args.get("end",   datetime.now().strftime("%Y-%m-%d"))
    min_trades = int(request.args.get("min_trades", 2))

    if not code:
        return jsonify({"error": "code 필수"}), 400

    # 카드에서 전달된 현재 파라미터를 base_cfg로 사용
    # (탐색 그리드에 없는 rsi_sell, rsi_period, rsi_buy3 등이 사용자 값으로 고정됨)
    base_cfg = {}
    for key in hb.DEFAULT_STOCK_CONFIG.keys():
        val = request.args.get(key)
        if val is not None:
            try:
                base_cfg[key] = float(val)
            except (ValueError, TypeError):
                pass

    db = _holding_db()
    try:
        candles = hb.get_stored_candles(db, code, start_date, end_date)
        if len(candles) < 20:
            return jsonify({"error": f"봉 부족: {len(candles)}개"}), 400

        results = hb.run_param_search(candles, min_trades=min_trades,
                                       base_cfg=base_cfg if base_cfg else None)
        return jsonify({"code": code, "top": results[:30]})
    finally:
        db.close()


# ────────────────────────────────────────────────────────────────────────────
# 메인 페이지
# ────────────────────────────────────────────────────────────────────────────

@app.route("/api/holding/min_candles/fetch", methods=["POST"])
def api_holding_min_candles_fetch():
    """60분봉 수집 API: 백그라운드 스레드로 실행, 즉시 job_id 반환"""
    body  = request.get_json(force=True, silent=True) or {}
    code  = body.get("code", "")
    start = body.get("start", "")
    end   = body.get("end",   datetime.now().strftime("%Y-%m-%d"))
    tf    = int(body.get("tf", 60))

    if not code or not start:
        return jsonify({"error": "code, start 필수"}), 400

    job_id = uuid.uuid4().hex[:8]
    _min_fetch_jobs[job_id] = {
        "status":       "running",
        "code":         code,
        "fetched":      0,      # 수집한 1분봉 수
        "saved":        0,      # DB에 저장된 60분봉 수
        "current_date": "",     # 현재 처리 중인 날짜
        "error":        None,
    }

    def _run():
        db = _holding_db()
        try:
            app_key, app_secret, base_url, mock = hb._get_kis_token(db)
            hb.fetch_and_store_min_candles(
                db, code, start, end,
                app_key, app_secret, base_url,
                tf_min=tf, mock=mock,
                progress=_min_fetch_jobs[job_id],
            )
            _min_fetch_jobs[job_id]["status"] = "done"
        except Exception as e:
            logger.error(f"60분봉 수집 오류 ({code}): {e}")
            _min_fetch_jobs[job_id]["status"] = "error"
            _min_fetch_jobs[job_id]["error"]  = str(e)
        finally:
            db.close()

    threading.Thread(target=_run, daemon=True).start()
    return jsonify({"job_id": job_id, "status": "started"})


@app.route("/api/holding/min_candles/status/<job_id>")
def api_holding_min_candles_status(job_id: str):
    """60분봉 수집 진행상황 폴링 엔드포인트"""
    job = _min_fetch_jobs.get(job_id)
    if not job:
        return jsonify({"error": "없는 job_id"}), 404
    return jsonify(job)


@app.route("/api/holding/min_candles/fetch_kiwoom", methods=["POST"])
def api_holding_min_candles_fetch_kiwoom():
    """
    키움 REST API (ka10080) 로 60분봉 수집 → holding_min_candles 저장.
    KIS와 달리 1회 호출에 900봉(약 128영업일), 연속조회로 2년치 수집 가능.

    필요 DB env_config 키: KIWOOM_APP_KEY, KIWOOM_APP_SECRET
    """
    body  = request.get_json(force=True, silent=True) or {}
    code  = body.get("code", "")
    start = body.get("start", "")
    end   = body.get("end", datetime.now().strftime("%Y-%m-%d"))

    if not code or not start:
        return jsonify({"error": "code, start 필수"}), 400

    job_id = uuid.uuid4().hex[:8]
    _min_fetch_jobs[job_id] = {
        "status":       "running",
        "code":         code,
        "source":       "kiwoom",
        "fetched":      0,
        "saved":        0,
        "current_date": "",
        "error":        None,
    }

    def _run():
        db = _holding_db()
        try:
            row = db.conn.execute(
                "SELECT * FROM env_config ORDER BY id DESC LIMIT 1"
            ).fetchone()
            if not row:
                raise RuntimeError("env_config 없음")
            r       = dict(row)
            is_mock = str(r.get("KIS_MOCK", "true")).lower() in ("true", "1", "yes")

            # KIS_MOCK 설정에 따라 키움 실전/모의 키 자동 선택
            # 우선순위: REAL/MOCK 전용 키 → 레거시 KIWOOM_APP_KEY
            if is_mock:
                kiwoom_key    = str(r.get("KIWOOM_APP_KEY_MOCK",    "") or "").strip()
                kiwoom_secret = str(r.get("KIWOOM_APP_SECRET_MOCK", "") or "").strip()
                mode_label    = "모의"
            else:
                kiwoom_key    = str(r.get("KIWOOM_APP_KEY_REAL",    "") or "").strip()
                kiwoom_secret = str(r.get("KIWOOM_APP_SECRET_REAL", "") or "").strip()
                mode_label    = "실전"

            # 전용 키 없으면 레거시 키로 폴백
            if not kiwoom_key or not kiwoom_secret:
                kiwoom_key    = str(r.get("KIWOOM_APP_KEY",    "") or "").strip()
                kiwoom_secret = str(r.get("KIWOOM_APP_SECRET", "") or "").strip()
                mode_label   += "(레거시키 사용)"

            if not kiwoom_key or not kiwoom_secret:
                raise RuntimeError(
                    f"키움 {mode_label} API 키 미설정.\n"
                    "DB env_config에 KIWOOM_APP_KEY_REAL(또는 KIWOOM_APP_KEY_MOCK) / "
                    "KIWOOM_APP_SECRET_REAL(또는 KIWOOM_APP_SECRET_MOCK) 추가 필요"
                )

            logger.info(f"키움 60분봉 수집: {code} [{mode_label}] {start}~{end}")
            rows = hb.fetch_60min_via_kiwoom(
                code, start, end, kiwoom_key, kiwoom_secret, is_mock=is_mock
            )
            _min_fetch_jobs[job_id]["fetched"] = len(rows)

            if not rows:
                _min_fetch_jobs[job_id]["status"] = "done"
                _min_fetch_jobs[job_id]["saved"]  = 0
                return

            saved = 0
            logger.info(f"키움 60분봉 DB 저장 시작: {code} {len(rows)}봉")
            err_sample = None
            for row_data in rows:
                try:
                    # MariaDB 문법: ON DUPLICATE KEY UPDATE (SQLite의 ON CONFLICT 아님)
                    db.conn.execute(
                        """
                        INSERT INTO holding_min_candles
                            (code, candle_dt, tf, open, high, low, close, volume)
                        VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
                        ON DUPLICATE KEY UPDATE
                            open=VALUES(open), high=VALUES(high),
                            low=VALUES(low), close=VALUES(close), volume=VALUES(volume)
                        """,
                        (
                            code,
                            row_data["candle_date"],
                            60,
                            row_data["open"],
                            row_data["high"],
                            row_data["low"],
                            row_data["close"],
                            row_data["volume"],
                        ),
                    )
                    saved += 1
                except Exception as row_err:
                    if err_sample is None:
                        err_sample = str(row_err)   # 첫 번째 오류만 샘플 보존
            db.conn.commit()
            if err_sample:
                logger.warning(f"⚠️ 키움 60분봉 일부 저장 실패 ({code}): {err_sample}")
            _min_fetch_jobs[job_id]["saved"]        = saved
            _min_fetch_jobs[job_id]["current_date"] = rows[-1]["candle_date"] if rows else ""
            _min_fetch_jobs[job_id]["status"]       = "done"
            logger.info(f"✅ 키움 60분봉 저장 완료: {code} {saved}/{len(rows)}봉")
        except Exception as e:
            logger.error(f"❌ 키움 60분봉 수집 오류 ({code}): {e}", exc_info=True)
            _min_fetch_jobs[job_id]["status"] = "error"
            _min_fetch_jobs[job_id]["error"]  = str(e)
        finally:
            db.close()

    threading.Thread(target=_run, daemon=True).start()
    return jsonify({"job_id": job_id, "status": "started"})


@app.route("/api/holding/min_backtest", methods=["GET"])
def api_holding_min_backtest():
    """60분봉 기반 백테스트 (run_backtest 재사용, candle_date=candle_dt 로 호환)"""
    code       = request.args.get("code", "")
    start_date = request.args.get("start", "")
    end_date   = request.args.get("end",   datetime.now().strftime("%Y-%m-%d"))
    tf         = int(request.args.get("tf", 60))

    if not code:
        return jsonify({"error": "code 필수"}), 400

    db = _holding_db()
    try:
        candles = hb.get_stored_min_candles(db, code, start_date, end_date, tf_min=tf)
        if not candles:
            stats = hb.get_min_candle_stats(db, code, tf_min=tf)
            if stats["count"] == 0:
                hint = f"📥 먼저 [60분봉 수집] 버튼으로 데이터를 수집하세요."
            else:
                hint = (f"DB에 {stats['count']}봉 있음 ({stats['min']} ~ {stats['max']})\n"
                        f"👉 백테스트 시작일을 '{stats['min'][:10]}' 이후로 설정하세요.")
            return jsonify({"error": f"60분봉 없음\n{hint}"}), 400

        if len(candles) < 20:
            stats = hb.get_min_candle_stats(db, code, tf_min=tf)
            hint  = (f"DB에 {stats['count']}봉 있음 ({stats['min']} ~ {stats['max']})\n"
                     f"👉 백테스트 날짜 범위를 넓혀 최소 20봉 이상 포함하세요.")
            return jsonify({
                "error": f"봉 부족: {len(candles)}개 (최소 20개 필요)\n{hint}"
            }), 400

        # 파라미터 오버라이드 (일봉 백테스트와 동일 방식)
        cfg = hb.get_stock_config(db, code)
        overrides = ["rsi_period","rsi_buy1","rsi_buy2","rsi_buy3","rsi_sell",
                     "take_profit_pct","stop_loss_pct","buy1_ratio","buy2_ratio",
                     "buy3_ratio","slot_money"]
        for k in overrides:
            v = request.args.get(k)
            if v is not None:
                cfg[k] = float(v)

        # tf_min=60 전달 → 52주 윈도우를 날짜 기반으로 정확히 계산
        result = hb.run_backtest(candles, cfg, tf_min=tf)
        result["code"]         = code
        result["candle_count"] = len(candles)
        result["tf"]           = tf
        result["params"]       = {k: cfg[k] for k in hb.DEFAULT_STOCK_CONFIG}
        return jsonify(result)
    finally:
        db.close()


@app.route("/api/holding/min_stats", methods=["GET"])
def api_holding_min_stats():
    """60분봉 보유 현황 (종목 카드에서 표시용)"""
    code = request.args.get("code", "")
    tf   = int(request.args.get("tf", 60))
    if not code:
        return jsonify({"error": "code 필수"}), 400
    db = _holding_db()
    try:
        return jsonify(hb.get_min_candle_stats(db, code, tf_min=tf))
    finally:
        db.close()


# ─────────────────────────────────────────────────────────────────────────────
# 스캘핑 / 꼬리잡기 파라미터 탐색 (Web 전용 coarse 그리드)
# ─────────────────────────────────────────────────────────────────────────────

# Web용 coarse 그리드 (256 조합 내외 → 약 5~10초)
_SCALP_WEB_GRID = {
    "rsi_oversold": [15, 18, 20, 25],
    "sl_pct":       [0.8, 1.0, 1.5, 2.0],
    "tp_pct":       [1.5, 2.0, 2.5, 3.5],
    "drop_rate":    [1.0, 1.5, 2.0, 3.0],
}
_TAIL_WEB_GRID = {
    "min_drop_rate":      [2.0, 3.0, 4.0, 5.0],
    "min_recovery_ratio": [30.0, 40.0, 50.0, 60.0],
    "sl_pct":             [2.0, 3.0, 5.0],
    "tp_pct":             [3.0, 5.0, 7.0, 10.0],
}


@app.route("/api/backtest/scalping/param_search", methods=["GET"])
def api_scalping_param_search():
    """스캘핑 파라미터 Grid Search (Web coarse, ~256 조합)"""
    start    = request.args.get("start", "")
    end      = request.args.get("end",   datetime.now().strftime("%Y-%m-%d"))
    top_n    = int(request.args.get("top", 10))
    min_tr   = int(request.args.get("min_trades", 3))

    # 그리드 외 파라미터: JS가 현재 UI 값을 전달하면 사용, 없으면 Python 기본값 (방어로직 포함)
    _SCALP_NON_GRID_KEYS = [
        "rsi_period", "rsi_overbought", "slot_money", "cooldown_min", "vol_mult",
        "trail_trigger", "trail_stop", "time_start", "time_end", "max_daily",
        "high_chase_thr", "max_daily_chg", "min_price", "max_loss_krw", "min_margin", "use_defense_filters",
    ]
    base_params = {"start": start, "end": end}
    for k in _SCALP_NON_GRID_KEYS:
        v = request.args.get(k)
        if v is not None:
            base_params[k] = v

    from itertools import product as iproduct
    keys   = list(_SCALP_WEB_GRID.keys())
    combos = list(iproduct(*[_SCALP_WEB_GRID[k] for k in keys]))

    results = []
    with app.test_client() as c:
        for vals in combos:
            p = dict(base_params)           # base(UI 값) 먼저
            p.update(dict(zip(keys, vals))) # 그리드 값으로 덮어쓰기
            r = c.get("/api/backtest/scalping?" + "&".join(f"{k}={v}" for k, v in p.items()))
            if r.status_code != 200:
                continue
            s = json.loads(r.data).get("summary", {})
            if s.get("total_trades", 0) < min_tr:
                continue
            results.append({
                "params":       p,
                "total_pnl":    s.get("total_pnl", 0),
                "win_rate":     s.get("win_rate", 0),
                "total_trades": s.get("total_trades", 0),
                "pf":           s.get("profit_factor", 0),
                "mdd":          s.get("max_drawdown", 0),
            })

    results.sort(key=lambda x: x["total_pnl"], reverse=True)
    return jsonify({"results": results[:top_n], "total_combos": len(combos)})


@app.route("/api/backtest/scalping/save_config", methods=["POST"])
def api_scalping_save_config():
    """스캘핑 파라미터를 env_config에 저장 (봇이 즉시 반영)"""
    body = request.get_json(force=True, silent=True) or {}
    db = _db()
    try:
        latest = db.get_latest_env()
        snap   = dict(latest["snapshot"]) if latest else {}
        # UI % 단위 → 봇이 사용하는 소수 단위로 변환
        if "rsi_oversold" in body:
            snap["SCALP_RSI_OVERSOLD"]   = str(float(body["rsi_oversold"]))
        if "rsi_overbought" in body:
            snap["SCALP_RSI_OVERBOUGHT"] = str(float(body["rsi_overbought"]))
        if "sl_pct" in body:
            snap["SCALP_STOP_LOSS_PCT"]  = str(float(body["sl_pct"]) / 100)
        if "tp_pct" in body:
            snap["SCALP_TAKE_PROFIT_PCT"]= str(float(body["tp_pct"]) / 100)
        if "drop_rate" in body:
            snap["SCALP_MIN_DROP_RATE"]  = str(float(body["drop_rate"]) / 100)
        if "trail_trigger" in body:
            snap["SCALP_ATR_UP_MULT"]    = str(float(body["trail_trigger"]))
        if "cooldown_min" in body:
            snap["SCALP_COOLDOWN_SEC"] = str(int(float(body["cooldown_min"])) * 60)
        # 스캘핑 전용 방어로직 (꼬리잡기와 값 분리)
        if "high_chase_thr" in body:
            snap["SCALP_HIGH_PRICE_CHASE_THRESHOLD"] = str(float(body["high_chase_thr"]))
        if "max_daily_chg" in body:
            snap["SCALP_MAX_DAILY_CHANGE_PCT"] = str(float(body["max_daily_chg"]))
        if "min_price" in body:
            snap["SCALP_MIN_PRICE"] = str(int(float(body["min_price"])))
        if "max_loss_krw" in body:
            snap["SCALP_MAX_LOSS_PER_TRADE_KRW"] = str(int(float(body["max_loss_krw"])))
        if "min_margin" in body:
            snap["SCALP_MIN_PROFIT_PCT"] = str(float(body["min_margin"]))  # % 단위 그대로 (0.2 등)
        if "min_drop_pct_for_loss_cut" in body:
            v = float(body["min_drop_pct_for_loss_cut"])
            snap["SCALP_MIN_DROP_PCT_FOR_LOSS_CUT"] = str(v) if v >= 1 else str(v * 100)  # % 단위 (1.5)
        if "use_defense_filters" in body:
            snap["SCALP_USE_DEFENSE_FILTERS"] = "true" if bool(body["use_defense_filters"]) else "false"
        saved_keys = [
            "SCALP_RSI_OVERSOLD", "SCALP_RSI_OVERBOUGHT", "SCALP_STOP_LOSS_PCT", "SCALP_TAKE_PROFIT_PCT", "SCALP_MIN_DROP_RATE",
            "SCALP_HIGH_PRICE_CHASE_THRESHOLD", "SCALP_MAX_DAILY_CHANGE_PCT", "SCALP_MIN_PRICE", "SCALP_MAX_LOSS_PER_TRADE_KRW", "SCALP_MIN_DROP_PCT_FOR_LOSS_CUT", "SCALP_MIN_PROFIT_PCT", "SCALP_USE_DEFENSE_FILTERS",
        ]
        env_id = db.insert_env_snapshot(snap)
        return jsonify({"ok": True, "env_id": env_id, "saved_keys": saved_keys})
    except Exception as e:
        logger.error(f"스캘핑 설정저장 오류: {e}")
        return jsonify({"error": str(e)}), 500
    finally:
        db.close()


@app.route("/api/backtest/tail/param_search", methods=["GET"])
def api_tail_param_search():
    """꼬리잡기 파라미터 Grid Search (Web coarse, ~192 조합)"""
    start  = request.args.get("start", "")
    end    = request.args.get("end",   datetime.now().strftime("%Y-%m-%d"))
    top_n  = int(request.args.get("top", 10))
    min_tr = int(request.args.get("min_trades", 3))

    # 그리드 외 파라미터: JS가 현재 UI 값을 전달하면 사용, 없으면 Python 기본값
    _TAIL_NON_GRID_KEYS = [
        "slot_money", "tail_ratio_min", "shoulder_min_high", "shoulder_cut_pct",
        "rsi_threshold", "cooldown_min", "time_start", "time_end", "max_daily",
        "tail_pct_min", "max_rec_3m", "high_chase_thr",
        "min_price", "max_daily_change", "ma20_max_above",
        "stop_atr_mult", "target_atr_mult", "max_loss_krw",
    ]
    base_params = {"start": start, "end": end}
    for k in _TAIL_NON_GRID_KEYS:
        v = request.args.get(k)
        if v is not None:
            base_params[k] = v

    from itertools import product as iproduct
    keys   = list(_TAIL_WEB_GRID.keys())
    combos = list(iproduct(*[_TAIL_WEB_GRID[k] for k in keys]))

    results = []
    with app.test_client() as c:
        for vals in combos:
            p = dict(base_params)           # base(UI 값) 먼저
            p.update(dict(zip(keys, vals))) # 그리드 값으로 덮어쓰기
            r = c.get("/api/backtest/tail?" + "&".join(f"{k}={v}" for k, v in p.items()))
            if r.status_code != 200:
                continue
            s = json.loads(r.data).get("summary", {})
            if s.get("total_trades", 0) < min_tr:
                continue
            results.append({
                "params":       p,
                "total_pnl":    s.get("total_pnl", 0),
                "win_rate":     s.get("win_rate", 0),
                "total_trades": s.get("total_trades", 0),
                "pf":           s.get("profit_factor", 0),
                "mdd":          s.get("max_drawdown", 0),
            })

    results.sort(key=lambda x: x["total_pnl"], reverse=True)
    return jsonify({"results": results[:top_n], "total_combos": len(combos)})


@app.route("/api/backtest/tail/save_config", methods=["POST"])
def api_tail_save_config():
    """꼬리잡기 파라미터를 env_config에 저장 (봇이 즉시 반영)"""
    body = request.get_json(force=True, silent=True) or {}
    db = _db()
    try:
        latest = db.get_latest_env()
        snap   = dict(latest["snapshot"]) if latest else {}
        if "min_drop_rate" in body:
            snap["MIN_DROP_RATE"]              = str(float(body["min_drop_rate"]) / 100)
        if "min_recovery_ratio" in body:
            snap["MIN_RECOVERY_RATIO_SHORT"]   = str(float(body["min_recovery_ratio"]) / 100)
        if "tail_ratio_min" in body:
            snap["TAIL_RATIO_MIN"]             = str(float(body["tail_ratio_min"]))
        if "sl_pct" in body:
            # 꼬리잡기 STOP_LOSS_PCT는 봇에서 음수 저장
            snap["STOP_LOSS_PCT"]              = str(-abs(float(body["sl_pct"])) / 100)
        if "tp_pct" in body:
            snap["TAKE_PROFIT_PCT"]            = str(float(body["tp_pct"]) / 100)
        if "shoulder_cut_pct" in body:
            snap["SHOULDER_CUT_PCT"]           = str(float(body["shoulder_cut_pct"]) / 100)
        if "shoulder_min_high" in body:
            snap["SHOULDER_MIN_HIGH_PCT"]      = str(float(body["shoulder_min_high"]) / 100)
        if "cooldown_min" in body:
            snap["REENTRY_COOLDOWN_SEC"]       = str(int(float(body["cooldown_min"])) * 60)
        if "rsi_threshold" in body:
            snap["RSI_OVERHEAT_THRESHOLD"]     = str(float(body["rsi_threshold"]))
        if "rsi_period" in body:
            snap["RSI_PERIOD"]                 = str(int(float(body["rsi_period"])))
        if "time_start" in body:
            snap["TIME_START"]                 = str(int(float(body["time_start"])))
        if "time_end" in body:
            snap["TIME_END"]                   = str(int(float(body["time_end"])))
        if "max_daily" in body:
            snap["MAX_STOCKS"]                 = str(int(float(body["max_daily"])))
        if "tail_pct_min" in body:
            snap["TAIL_PCT_MIN"]               = str(float(body["tail_pct_min"]) / 100)
        if "max_rec_3m" in body:
            snap["MAX_RECOVERY_RATIO_3M"]      = str(float(body["max_rec_3m"]) / 100)
        if "high_chase_thr" in body:
            snap["HIGH_PRICE_CHASE_THRESHOLD"] = str(float(body["high_chase_thr"]) / 100)
        # ── 방어 로직 (V3) 추가 저장 (tail_engine이 읽는 DB 키 사용) ──
        if "min_price" in body:
            snap["MIN_PRICE_TAIL"]             = str(float(body["min_price"]))
        if "max_daily_change" in body:
            snap["MAX_DAILY_CHANGE_PCT"]        = str(float(body["max_daily_change"]))
        if "ma20_max_above" in body:
            snap["MA20_MAX_ABOVE_PCT"]          = str(float(body["ma20_max_above"]))
        if "stop_atr_mult" in body:
            snap["STOP_ATR_MULTIPLIER_TAIL"]    = str(float(body["stop_atr_mult"]))
        if "target_atr_mult" in body:
            snap["TARGET_ATR_MULTIPLIER_TAIL"]  = str(float(body["target_atr_mult"]))
        if "max_loss_krw" in body:
            snap["MAX_LOSS_PER_TRADE_KRW"]      = str(int(body["max_loss_krw"]))
        if "min_drop_pct_for_loss_cut" in body:
            v = float(body["min_drop_pct_for_loss_cut"])
            snap["MIN_DROP_PCT_FOR_LOSS_CUT"]   = str(v) if v >= 1 else str(v * 100)  # % 단위로 저장 (1.5 또는 0.015 → 1.5)
        env_id = db.insert_env_snapshot(snap)
        return jsonify({"ok": True, "env_id": env_id, "saved_keys": [
            "MIN_DROP_RATE","MIN_RECOVERY_RATIO_SHORT","STOP_LOSS_PCT",
            "TAKE_PROFIT_PCT","TAIL_RATIO_MIN","TAIL_PCT_MIN","SHOULDER_CUT_PCT",
            "REENTRY_COOLDOWN_SEC","RSI_OVERHEAT_THRESHOLD","RSI_PERIOD",
            "TIME_START","TIME_END","MAX_STOCKS","MAX_RECOVERY_RATIO_3M","HIGH_PRICE_CHASE_THRESHOLD",
            "MIN_PRICE_TAIL","MAX_DAILY_CHANGE_PCT","MA20_MAX_ABOVE_PCT","STOP_ATR_MULTIPLIER_TAIL","TARGET_ATR_MULTIPLIER_TAIL","MAX_LOSS_PER_TRADE_KRW","MIN_DROP_PCT_FOR_LOSS_CUT"
        ]})
    except Exception as e:
        logger.error(f"꼬리잡기 설정저장 오류: {e}")
        return jsonify({"error": str(e)}), 500
    finally:
        db.close()


@app.route("/api/env/params", methods=["GET"])
def api_env_params():
    """
    env_config 최신 스냅샷에서 스캘핑·꼬리잡기 UI 초기값 반환.
    % 단위 변환 및 음수 → 양수 변환까지 수행해 JS가 바로 input.value에 넣을 수 있도록 함.
    값이 DB에 없으면 null 반환 → JS에서 기존 HTML 기본값 유지.
    """
    db = _db()
    try:
        latest = db.get_latest_env()
        snap   = (latest or {}).get("snapshot", {})

        def fv(key):
            """DB 값을 float으로 파싱, 없으면 None"""
            v = snap.get(key)
            if v is None or v == "":
                return None
            try:
                return float(v)
            except (ValueError, TypeError):
                return None

        def smart_pct(key):
            """
            DB 저장 형식이 소수(0.03) 또는 퍼센트(3.0) 중 어느 쪽이든
            UI에 항상 퍼센트 단위(3.0)로 반환.
            - 절댓값 < 0.5 → 소수 형식 → ×100
            - 절댓값 >= 0.5 → 이미 퍼센트 형식 → 그대로
            - STOP_LOSS_PCT처럼 음수 저장된 경우 → 양수 변환
            """
            v = fv(key)
            if v is None:
                return None
            av = abs(v)
            if av == 0:
                return 0.0
            result = av if av >= 0.5 else round(av * 100, 3)
            return round(result, 3)

        def sec_to_min(key):
            """초 → 분, None 유지"""
            v = fv(key)
            return round(v / 60) if v is not None else None

        def _ratio_to_pct(val, default):
            """비율(0~1)을 폼 퍼센트 표시용(80, 96 등)으로. DB 0.8 → 80 반환."""
            if val is None:
                return default
            try:
                v = float(val)
                if 0 < v <= 1:
                    return round(v * 100, 2)
                if v > 1:
                    return round(v, 2)
            except (ValueError, TypeError):
                pass
            return default

        # 스캘핑 방어로직: SCALP_* 우선, 없으면 꼬리잡기 공용 키 fallback (전략별 값 분리)
        scalp_high_chase = fv("SCALP_HIGH_PRICE_CHASE_THRESHOLD") or fv("HIGH_PRICE_CHASE_THRESHOLD")
        scalp_max_daily  = fv("SCALP_MAX_DAILY_CHANGE_PCT") or fv("MAX_DAILY_CHANGE_PCT")
        scalp_min_price  = fv("SCALP_MIN_PRICE") or fv("MIN_PRICE_TAIL")
        scalp_max_loss   = fv("SCALP_MAX_LOSS_PER_TRADE_KRW") or fv("MAX_LOSS_PER_TRADE_KRW")
        scalp_min_margin = fv("SCALP_MIN_PROFIT_PCT")
        scalp_use_def    = fv("SCALP_USE_DEFENSE_FILTERS")
        return jsonify({
            "scalp": {
                "rsi_oversold":  fv("SCALP_RSI_OVERSOLD"),          # 과매도 임계값 (숫자 그대로)
                "rsi_overbought": fv("SCALP_RSI_OVERBOUGHT"),        # 과열 임계값 (이 값 초과 시 진입 금지)
                "sl_pct":        smart_pct("SCALP_STOP_LOSS_PCT"),   # 소수/퍼센트 자동감지
                "tp_pct":        smart_pct("SCALP_TAKE_PROFIT_PCT"),
                "drop_rate":     smart_pct("SCALP_MIN_DROP_RATE"),
                "trail_trigger": fv("SCALP_ATR_UP_MULT"),            # 배수 그대로
                "cooldown_min":  sec_to_min("SCALP_COOLDOWN_SEC"),   # 스캘핑 전용 (꼬리잡기 REENTRY_COOLDOWN_SEC 와 분리)
                "slot_money":    fv("SLOT_MONEY_DEFAULT"),
                # 방어로직 (스캘핑 전용 env — 웹 입력란·저장·백테스트 반영)
                "high_chase_thr":  float(scalp_high_chase) if scalp_high_chase not in (None, "") else 0.96,
                "max_daily_chg":   float(scalp_max_daily)  if scalp_max_daily  not in (None, "") else 20.0,
                "min_price":       float(scalp_min_price)  if scalp_min_price  not in (None, "") else 1000.0,
                "max_loss_krw":    int(float(scalp_max_loss))   if scalp_max_loss   not in (None, "") else 200000,
                "min_drop_pct_for_loss_cut": (lambda v: (v * 100 if v is not None and v < 1 else v) if v is not None else 1.5)(fv("SCALP_MIN_DROP_PCT_FOR_LOSS_CUT")),
                "min_margin":      float(scalp_min_margin) if scalp_min_margin not in (None, "") else 0.2,
                "use_defense_filters": str(scalp_use_def).strip().lower() in ("1", "true", "y", "yes", "on") if scalp_use_def not in (None, "") else True,
            },
            "tail": {
                "drop":         smart_pct("MIN_DROP_RATE"),
                "rec":          smart_pct("MIN_RECOVERY_RATIO_SHORT"),
                "tail_ratio":   fv("TAIL_RATIO_MIN"),
                "tail_pct_min": smart_pct("TAIL_PCT_MIN"),
                "sl_pct":       smart_pct("STOP_LOSS_PCT"),            # 음수 → 양수도 자동처리
                "tp_pct":       smart_pct("TAKE_PROFIT_PCT"),
                "smin":         smart_pct("SHOULDER_MIN_HIGH_PCT"),
                "scut":         smart_pct("SHOULDER_CUT_PCT"),
                "cool":         sec_to_min("REENTRY_COOLDOWN_SEC"),
                "rsi":          fv("RSI_OVERHEAT_THRESHOLD"),
                "rsi_period":   int(fv("RSI_PERIOD") or 14),           # RSI 기간 (실매·파라서치와 동일)
                "time_start":   int(fv("TIME_START") or 930),          # 매수시작 HHMM
                "time_end":     int(fv("TIME_END") or 1500),           # 매수종료 HHMM
                "max_daily":    int(fv("MAX_STOCKS") or 3),            # 일일최대매수 (실매 MAX_STOCKS)
                # 비율(0~1) 저장값을 폼 퍼센트 표시용으로 80·96 형태로 반환 (백테 API는 80→0.8로 변환)
                "max_rec_3m":   _ratio_to_pct(fv("MAX_RECOVERY_RATIO_3M"), 80),
                "high_chase":   _ratio_to_pct(fv("HIGH_PRICE_CHASE_THRESHOLD"), 96),
                # 방어 로직 (V3) — tail_engine과 동일 DB 키
                "min_price":        fv("MIN_PRICE_TAIL"),
                "max_daily_change": fv("MAX_DAILY_CHANGE_PCT"),
                "ma20_max_above":   fv("MA20_MAX_ABOVE_PCT"),
                "stop_atr_mult":    fv("STOP_ATR_MULTIPLIER_TAIL"),
                "target_atr_mult":  fv("TARGET_ATR_MULTIPLIER_TAIL"),
                "max_loss_krw":     fv("MAX_LOSS_PER_TRADE_KRW"),
                "min_drop_pct_for_loss_cut": (lambda v: (v * 100 if v is not None and v < 1 else v) if v is not None else 1.5)(fv("MIN_DROP_PCT_FOR_LOSS_CUT")),  # 금액손실컷 발동 최소 하락률(%)
            },
        })
    finally:
        db.close()


HTML_TEMPLATE = r"""<!DOCTYPE html>
<html lang="ko" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>📈 KIS Quant 백테스트 대시보드</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
<style>
  /* ── Bootstrap dark-theme 변수 오버라이드 ── */
  [data-bs-theme="dark"] {
    --bs-body-bg:              #0d1117;
    --bs-body-color:           #e6edf3;
    --bs-border-color:         #21262d;
    --bs-secondary-bg:         #161b22;
    --bs-tertiary-bg:          #161b22;
    --bs-table-color:          #e6edf3;
    --bs-table-bg:             transparent;
    --bs-table-border-color:   #21262d;
    --bs-table-hover-bg:       rgba(88,166,255,.07);
    --bs-table-hover-color:    #e6edf3;
    --bs-form-control-bg:      #21262d;
    --bs-form-control-color:   #e6edf3;
    --bs-input-bg:             #21262d;
    --bs-input-color:          #e6edf3;
    --bs-link-color:           #58a6ff;
  }
  :root {
    --bg: #0d1117; --surface: #161b22; --border: #21262d;
    --text: #e6edf3; --muted: #8b949e; --accent: #58a6ff;
    --green: #3fb950; --red: #f85149; --yellow: #d29922; --purple: #bc8cff;
  }
  * { box-sizing: border-box; }
  body { background: var(--bg) !important; color: var(--text) !important;
         font-family: 'Segoe UI', sans-serif; font-size: 14px; }

  /* 네비 / 탭 */
  .navbar-brand { font-weight: 700; letter-spacing: 1px; color: var(--accent) !important; }
  .nav-tabs .nav-link { color: var(--muted); border: none; padding: 10px 20px; }
  .nav-tabs .nav-link.active { color: var(--accent); border-bottom: 2px solid var(--accent); background: transparent; }
  .nav-tabs { border-bottom: 1px solid var(--border); }

  /* 카드 */
  .card { background: var(--surface) !important; border: 1px solid var(--border) !important; border-radius: 10px; }
  .card-title { font-size: 12px; text-transform: uppercase; letter-spacing: 1px; color: var(--muted); margin-bottom: 4px; }
  .stat-val { font-size: 26px; font-weight: 700; color: var(--text); }
  .stat-val.green { color: var(--green) !important; }
  .stat-val.red   { color: var(--red)   !important; }

  /* 폼 컨트롤 */
  .form-control, .form-select, input[type=number], input[type=date] {
    background: #21262d !important; border: 1px solid #30363d !important;
    color: var(--text) !important; border-radius: 6px;
  }
  .form-control:focus, .form-select:focus, input:focus {
    background: #21262d !important; color: var(--text) !important;
    border-color: var(--accent) !important; box-shadow: none !important;
  }
  .btn-primary { background: var(--accent) !important; border: none !important; font-weight: 600; color: #0d1117 !important; }
  .btn-primary:hover { background: #79b8ff !important; }
  label, .form-label { color: var(--muted) !important; font-size: 12px; }

  /* 테이블 — Bootstrap dark theme가 대부분 처리, 남은 것만 보정 */
  .table { font-size: 12px; }
  .table td, .table th { color: var(--text) !important; border-color: var(--border) !important; padding: 6px 10px; }
  .table thead th { color: var(--muted) !important; }
  .table-responsive { max-height: 420px; overflow-y: auto; }
  .table-responsive::-webkit-scrollbar { width: 5px; }
  .table-responsive::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }

  /* 배지 */
  .badge-win  { background: rgba(63,185,80,.2);   color: var(--green)  !important; padding: 2px 8px; border-radius: 20px; }
  .badge-loss { background: rgba(248,81,73,.2);   color: var(--red)    !important; padding: 2px 8px; border-radius: 20px; }
  .badge-flat { background: rgba(210,153,34,.15); color: var(--yellow) !important; padding: 2px 8px; border-radius: 20px; }

  /* 스피너 */
  .spin-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.55);
                  z-index: 9999; align-items: center; justify-content: center; }
  .spin-overlay.show { display: flex; }
  .spinner { width: 48px; height: 48px; border: 4px solid var(--border);
             border-top-color: var(--accent); border-radius: 50%; animation: spin .9s linear infinite; }
  @keyframes spin { to { transform: rotate(360deg); } }

  /* 기타 */
  canvas { background: transparent !important; }
  .section-title { font-size: 13px; font-weight: 600; color: var(--muted);
                   text-transform: uppercase; letter-spacing: 1px; margin: 24px 0 12px; }
  input[type=radio] { accent-color: var(--accent); }
  .ratio-bar { height: 8px; border-radius: 4px; background: var(--border); margin-top: 4px; overflow: hidden; }
  .ratio-fill-g { height: 100%; border-radius: 4px 0 0 4px; background: var(--green); display: inline-block; }
  .ratio-fill-r { height: 100%; border-radius: 0 4px 4px 0; background: var(--red);   display: inline-block; float: right; }
  .text-pnl-pos { color: var(--green) !important; }
  .text-pnl-neg { color: var(--red)   !important; }
  /* 파라미터 설명 툴팁 */
  .param-help { font-size: 10px; color: var(--muted); margin-top: 2px; }
</style>
</head>
<body>

<!-- 로딩 오버레이 -->
<div class="spin-overlay" id="spinner">
  <div class="spinner"></div>
</div>

<nav class="navbar px-4 py-3" style="background:var(--surface); border-bottom:1px solid var(--border)">
  <span class="navbar-brand">📈 KIS Quant Dashboard</span>
  <span class="text-muted" id="lastRefresh" style="font-size:12px"></span>
</nav>

<div class="container-fluid px-4 py-3">

  <!-- 탭 -->
  <ul class="nav nav-tabs mb-4" id="mainTab">
    <li class="nav-item"><a class="nav-link active" data-tab="actual"   href="#">📊 실거래 분석</a></li>
    <li class="nav-item"><a class="nav-link"         data-tab="backtest" href="#">🧪 스캘핑 백테스트</a></li>
    <li class="nav-item"><a class="nav-link"         data-tab="tail"     href="#">🎣 꼬리잡기 백테스트</a></li>
    <li class="nav-item"><a class="nav-link"         data-tab="holding"  href="#">📈 홀딩 전략</a></li>
  </ul>

  <!-- ═══ 실거래 분석 탭 ═══════════════════════════════════════════════════ -->
  <div id="tab-actual">
    <div class="card p-3 mb-3">
      <div class="row g-3 align-items-end">
        <div class="col-auto">
          <label class="form-label param-row" style="color:var(--muted);font-size:12px">전략 선택</label><br>
          <div class="d-flex gap-3 mt-1">
            <label class="d-flex align-items-center gap-1">
              <input type="radio" name="act_strategy" value="SCALP_RSI_REVERSAL" checked> 스캘핑
            </label>
            <label class="d-flex align-items-center gap-1">
              <input type="radio" name="act_strategy" value="SHORT_ANT_SHAKING"> 꼬리잡기
            </label>
          </div>
        </div>
        <div class="col-auto">
          <label class="form-label param-row">시작일</label>
          <input type="date" class="form-control" id="act_start" style="width:150px">
        </div>
        <div class="col-auto">
          <label class="form-label param-row">종료일</label>
          <input type="date" class="form-control" id="act_end" style="width:150px">
        </div>
        <div class="col-auto">
          <button class="btn btn-primary" onclick="loadActual()">🔍 조회</button>
        </div>
      </div>
    </div>

    <!-- 요약 카드 -->
    <div class="row g-3 mb-3" id="act_summary_row">
      <div class="col-6 col-md-3 col-lg-2"><div class="card p-3">
        <div class="card-title">총 거래</div><div class="stat-val" id="a_total">-</div>
      </div></div>
      <div class="col-6 col-md-3 col-lg-2"><div class="card p-3">
        <div class="card-title">승률</div><div class="stat-val" id="a_winrate">-</div>
      </div></div>
      <div class="col-6 col-md-3 col-lg-2"><div class="card p-3">
        <div class="card-title">순손익</div><div class="stat-val" id="a_pnl">-</div>
      </div></div>
      <div class="col-6 col-md-3 col-lg-2"><div class="card p-3">
        <div class="card-title">Profit Factor</div><div class="stat-val" id="a_pf">-</div>
      </div></div>
      <div class="col-6 col-md-3 col-lg-2"><div class="card p-3">
        <div class="card-title">최대 낙폭(MDD)</div><div class="stat-val red" id="a_mdd">-</div>
      </div></div>
      <div class="col-6 col-md-3 col-lg-2"><div class="card p-3">
        <div class="card-title">평균 보유(분)</div><div class="stat-val" id="a_hold">-</div>
      </div></div>
    </div>
    <div id="act_filter_note" class="mb-2" style="display:none;font-size:12px;color:var(--muted)"></div>
    <!-- 승/패 바 -->
    <div class="card p-3 mb-3" id="act_winbar_card" style="display:none">
      <div class="d-flex justify-content-between mb-1">
        <span style="color:var(--green)" id="a_win_label">-</span>
        <span style="color:var(--red)"   id="a_loss_label">-</span>
      </div>
      <div class="ratio-bar">
        <span class="ratio-fill-g" id="a_ratio_g" style="width:50%"></span>
        <span class="ratio-fill-r" id="a_ratio_r" style="width:50%"></span>
      </div>
    </div>

    <div class="row g-3">
      <!-- 누적 손익 곡선 -->
      <div class="col-12 col-lg-7">
        <div class="card p-3">
          <div class="card-title mb-2">누적 손익 곡선</div>
          <canvas id="a_equity_chart" height="220"></canvas>
        </div>
      </div>
      <!-- 매도 이유 / 상위종목 -->
      <div class="col-12 col-lg-5">
        <div class="card p-3 mb-3">
          <div class="card-title mb-2">매도 이유 분포</div>
          <canvas id="a_reason_chart" height="180"></canvas>
        </div>
        <div class="card p-3">
          <div class="card-title mb-2">종목별 손익 TOP10</div>
          <canvas id="a_code_chart" height="180"></canvas>
        </div>
      </div>
    </div>

    <!-- 일별 P&L 바 -->
    <div class="card p-3 mt-3">
      <div class="card-title mb-2">일별 손익</div>
      <canvas id="a_daily_chart" height="150"></canvas>
    </div>

    <!-- 거래 목록 -->
    <div class="section-title">거래 내역</div>
    <div class="card p-3">
      <div class="table-responsive">
        <table class="table table-hover" id="act_table">
          <thead><tr>
            <th>매도일시</th><th>종목</th><th>매수가</th><th>매도가</th>
            <th>수량</th><th>손익(원)</th><th>수익률</th><th>보유(분)</th><th>매도사유</th>
          </tr></thead>
          <tbody id="act_tbody"></tbody>
        </table>
      </div>
    </div>
  </div>

  <!-- ═══ 스캘핑 백테스트 탭 ═══════════════════════════════════════════════ -->
  <div id="tab-backtest" style="display:none">
    <div class="card p-3 mb-3">
      <div class="row g-2 align-items-end">
        <!-- 기간 -->
        <div class="col-6 col-md-2">
          <label class="form-label param-row">시작일</label>
          <input type="date" class="form-control" id="bt_start">
        </div>
        <div class="col-6 col-md-2">
          <label class="form-label param-row">종료일</label>
          <input type="date" class="form-control" id="bt_end">
        </div>
        <!-- 기본 파라미터 -->
        <div class="col-4 col-md-1">
          <label class="form-label param-row">RSI 기간</label>
          <input type="number" class="form-control" id="bt_rsi_period" value="3" min="2" max="14">
        </div>
        <div class="col-4 col-md-1">
          <label class="form-label param-row">RSI 과매도</label>
          <input type="number" class="form-control" id="bt_rsi_oversold" value="18" min="10" max="45" step="1">
        </div>
        <div class="col-4 col-md-1">
          <label class="form-label param-row" title="이 값 초과 시 고점추격으로 진입 금지">RSI 과열</label>
          <input type="number" class="form-control" id="bt_rsi_overbought" value="75" min="50" max="100" step="1">
        </div>
        <div class="col-4 col-md-1">
          <label class="form-label param-row">손절(%)</label>
          <input type="number" class="form-control" id="bt_sl" value="1.0" min="0.3" max="5" step="0.1">
        </div>
        <div class="col-4 col-md-1">
          <label class="form-label param-row">익절(%)</label>
          <input type="number" class="form-control" id="bt_tp" value="2.5" min="0.3" max="5" step="0.1">
        </div>
        <div class="col-4 col-md-1">
          <label class="form-label param-row">낙폭필터(%)</label>
          <input type="number" class="form-control" id="bt_drop" value="2.0" min="0.5" max="5" step="0.1">
        </div>
        <div class="col-4 col-md-1">
          <label class="form-label param-row">투자금(원)</label>
          <input type="number" class="form-control" id="bt_slot" value="1000000" min="100000" step="100000">
        </div>
      </div>
      <!-- 고급 필터 (2행) -->
      <div class="row g-2 align-items-end mt-1">
        <div class="col-4 col-md-1">
          <label class="form-label param-row" title="손익 후 같은 종목 재진입 금지 시간(분)">쿨다운(분)</label>
          <input type="number" class="form-control" id="bt_cooldown" value="10" min="0" max="60" step="5">
        </div>
        <div class="col-4 col-md-1">
          <label class="form-label param-row" title="현재봉 거래량 > 20봉평균 × N배. 0=비활성">거래량배수(0=끔)</label>
          <input type="number" class="form-control" id="bt_vol_mult" value="0" min="0" max="5" step="0.1">
        </div>
        <div class="col-4 col-md-1">
          <label class="form-label param-row" title="트레일링스탑 발동 수익률(%). 0=비활성">트레일발동%</label>
          <input type="number" class="form-control" id="bt_trail_trig" value="0.7" min="0" max="3" step="0.1">
        </div>
        <div class="col-4 col-md-1">
          <label class="form-label param-row" title="고점 대비 이 % 하락 시 청산">트레일추적%</label>
          <input type="number" class="form-control" id="bt_trail_stop" value="0.4" min="0.1" max="3" step="0.1">
        </div>
        <div class="col-4 col-md-1">
          <label class="form-label param-row" title="매수 가능 시작 시각(HHMM)">매수시작</label>
          <input type="number" class="form-control" id="bt_time_start" value="830" min="900" max="1500" step="100">
        </div>
        <div class="col-4 col-md-1">
          <label class="form-label param-row" title="매수 가능 종료 시각(HHMM)">매수종료</label>
          <input type="number" class="form-control" id="bt_time_end" value="1530" min="900" max="1530" step="100">
        </div>
        <div class="col-4 col-md-1">
          <label class="form-label param-row" title="종목당 하루 최대 거래횟수">일일최대매수</label>
          <input type="number" class="form-control" id="bt_max_daily" value="3" min="1" max="10">
        </div>
      </div>
      <!-- 방어로직 (스캘핑 전용 env — param_search/엔진과 동일) -->
      <div class="row g-2 align-items-end mt-1">
        <div class="col-12 col-md-2">
          <label class="form-label param-row" title="OFF면 진입 방어(낙폭/고점추격/급등/최소가격)를 비활성화하고, 손절/익절/장마감청산은 유지">방어로직</label>
          <div class="form-check mt-1">
            <input class="form-check-input" type="checkbox" id="bt_use_defense" checked>
            <label class="form-check-label" for="bt_use_defense">진입 방어 ON</label>
          </div>
        </div>
        <div class="col-4 col-md-1">
          <label class="form-label param-row" title="당일고가 대비 이 비율 이하면 진입 허용">고점추격방지</label>
          <input type="number" class="form-control" id="bt_high_chase" value="0.96" min="0.5" max="1" step="0.01">
        </div>
        <div class="col-4 col-md-1">
          <label class="form-label param-row" title="당일 등락률 이 값 초과 시 진입 금지(%)">급등주필터(%)</label>
          <input type="number" class="form-control" id="bt_max_daily_chg" value="20" min="0" max="100" step="1">
        </div>
        <div class="col-4 col-md-1">
          <label class="form-label param-row" title="이 가격 미만 종목 진입 금지(원)">최소가격(원)</label>
          <input type="number" class="form-control" id="bt_min_price" value="1000" min="0" step="100">
        </div>
        <div class="col-4 col-md-1">
          <label class="form-label param-row" title="종목당 1회 최대 손실 한도(원)">1회최대손실(원)</label>
          <input type="number" class="form-control" id="bt_max_loss_krw" value="200000" min="0" step="10000">
        </div>
        <div class="col-4 col-md-1">
          <label class="form-label param-row" title="본절사수 시 최소 순이익 마진(%)">본절마진(%)</label>
          <input type="number" class="form-control" id="bt_min_margin" value="0.2" min="0" max="2" step="0.1">
        </div>
        <div class="col-12 col-md-auto mt-2 d-flex gap-2 flex-wrap">
          <button class="btn btn-primary px-4" onclick="runBacktest()">🚀 백테스트 실행</button>
          <button class="btn btn-warning px-3" onclick="runScalpParamSearch()">🔍 파라미터탐색</button>
          <button class="btn btn-success px-3" onclick="saveScalpConfig()">💾 봇에 설정저장</button>
        </div>
      </div>
      <!-- 파라미터 탐색 결과 (접기/펼치기) -->
      <div id="scalp_search_result" style="display:none" class="mt-3">
        <div class="card-title" style="font-size:13px;color:var(--accent)">🔍 파라미터탐색 결과 (수익 기준 정렬)</div>
        <div class="table-responsive">
          <table class="table table-sm table-hover" style="font-size:12px">
            <thead><tr><th>#</th><th>RSI과매도</th><th>RSI과열</th><th>손절%</th><th>익절%</th><th>낙폭%</th><th>방어</th>
              <th>손익(원)</th><th>승률%</th><th>거래수</th><th>PF</th><th>MDD</th><th></th></tr></thead>
            <tbody id="scalp_search_tbody"></tbody>
          </table>
        </div>
      </div>
      <!-- 파라미터 설명 -->
      <div class="mt-3 p-2 rounded" style="background:#0d1117;border:1px solid #21262d;font-size:11px;color:var(--muted)">
        <b style="color:var(--accent)">📖 파라미터 설명 (실제 봇 <code>kis_scalping_ver1.py</code> 기준)</b>
        <div class="row g-1 mt-1">
          <div class="col-12 col-md-6">
            <table style="width:100%;border-collapse:collapse">
              <tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">RSI 기간</td><td>RSI 계산 캔들 수 → 봇: <b>3</b> (SCALP_RSI_PERIOD 고정)</td></tr>
              <tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">RSI 과매도</td><td>이 값 <b>이하</b>일 때 매수 신호 → 봇 DB: <b>SCALP_RSI_OVERSOLD=25</b></td></tr>
              <tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">RSI 과열</td><td>이 값 <b>초과</b>면 진입 금지 (고점 추격 방지) → 봇 DB: <b>SCALP_RSI_OVERBOUGHT=75</b></td></tr>
              <tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">손절(%)</td><td>매수가 대비 하락 시 손절 → 봇 DB: <b>SCALP_STOP_LOSS_PCT=1.5%</b></td></tr>
              <tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">익절(%)</td><td>매수가 대비 상승 시 익절 → 봇 DB: <b>SCALP_TAKE_PROFIT_PCT=1.5%</b></td></tr>
              <tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">낙폭필터(%)</td><td>당일 시가→저가 낙폭이 이 값 이상이어야 진입 → 봇 DB: <b>SCALP_MIN_DROP_RATE=1.5%</b></td></tr>
              <tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">투자금(원)</td><td>1회 최대 투자금 → 봇: <b>MAX_LOSS_PER_TRADE_KRW ÷ SCALP_STOP_LOSS_PCT</b> 공식 적용<br><span style="opacity:.7">예) 200,000÷0.015 = 13,333,333원</span></td></tr>
            </table>
          </div>
          <div class="col-12 col-md-6">
            <table style="width:100%;border-collapse:collapse">
              <tr><td style="color:#d29922;padding:1px 6px;white-space:nowrap">쿨다운(분)</td><td>청산 후 같은 종목 재진입 금지 시간 → <span style="opacity:.7">봇에는 없음(백테스트 전용)</span></td></tr>
              <tr><td style="color:#d29922;padding:1px 6px;white-space:nowrap">거래량배수</td><td>신호봉 거래량 > 20봉 평균 × N배. 0=비활성 → <span style="opacity:.7">봇에는 없음</span></td></tr>
              <tr><td style="color:#d29922;padding:1px 6px;white-space:nowrap">트레일발동%</td><td>수익이 이 값 이상 되면 트레일링스탑 발동 → <span style="opacity:.7">봇에는 없음</span></td></tr>
              <tr><td style="color:#d29922;padding:1px 6px;white-space:nowrap">트레일추적%</td><td>고점 대비 이 값 하락 시 자동 청산 → <span style="opacity:.7">봇에는 없음</span></td></tr>
              <tr><td style="color:#d29922;padding:1px 6px;white-space:nowrap">매수시작/종료</td><td>매수 가능 시간대 (HHMM) → 봇: 장중 전체 허용</td></tr>
              <tr><td style="color:#d29922;padding:1px 6px;white-space:nowrap">일일최대매수</td><td>종목당 하루 최대 거래횟수 → <span style="opacity:.7">봇에는 없음</span></td></tr>
            </table>
          </div>
        </div>
        <div class="mt-1">⚡ 진입가 = <b>신호봉 다음 봉 시가</b> (실제 주문과 동일하게 1봉 지연) | 수수료 0.015%×2 + 거래세 0.18% 자동 차감</div>
      </div>
    </div>

    <!-- 파라미터 요약 -->
    <div id="bt_params_bar" class="text-muted mb-3" style="font-size:12px;display:none"></div>

    <!-- 요약 카드 -->
    <div class="row g-3 mb-3">
      <div class="col-6 col-md-3 col-lg-2"><div class="card p-3">
        <div class="card-title">총 거래</div><div class="stat-val" id="b_total">-</div>
      </div></div>
      <div class="col-6 col-md-3 col-lg-2"><div class="card p-3">
        <div class="card-title">승률</div><div class="stat-val" id="b_winrate">-</div>
      </div></div>
      <div class="col-6 col-md-3 col-lg-2"><div class="card p-3">
        <div class="card-title">순손익(수수료·세금 포함)</div><div class="stat-val" id="b_pnl">-</div>
      </div></div>
      <div class="col-6 col-md-3 col-lg-2"><div class="card p-3">
        <div class="card-title">Profit Factor</div><div class="stat-val" id="b_pf">-</div>
      </div></div>
      <div class="col-6 col-md-3 col-lg-2"><div class="card p-3">
        <div class="card-title">최대 낙폭(MDD)</div><div class="stat-val red" id="b_mdd">-</div>
      </div></div>
      <div class="col-6 col-md-3 col-lg-2"><div class="card p-3">
        <div class="card-title">평균 보유(분)</div><div class="stat-val" id="b_hold">-</div>
      </div></div>
    </div>
    <!-- Buy & Hold 비교 -->
    <!-- 승/패 바 -->
    <div class="card p-3 mb-3" id="bt_winbar_card" style="display:none">
      <div class="d-flex justify-content-between mb-1">
        <span style="color:var(--green)" id="b_win_label">-</span>
        <span style="color:var(--red)"   id="b_loss_label">-</span>
      </div>
      <div class="ratio-bar">
        <span class="ratio-fill-g" id="b_ratio_g" style="width:50%"></span>
        <span class="ratio-fill-r" id="b_ratio_r" style="width:50%"></span>
      </div>
    </div>

    <div class="row g-3">
      <div class="col-12 col-lg-8">
        <div class="card p-3">
          <div class="card-title mb-2">누적 손익 곡선 (가상)</div>
          <canvas id="b_equity_chart" height="220"></canvas>
        </div>
      </div>
      <div class="col-12 col-lg-4">
        <div class="card p-3">
          <div class="card-title mb-2">매도 이유 분포</div>
          <canvas id="b_reason_chart" height="200"></canvas>
        </div>
      </div>
    </div>

    <div class="card p-3 mt-3">
      <div class="card-title mb-2">일별 손익 (가상)</div>
      <canvas id="b_daily_chart" height="150"></canvas>
    </div>

    <!-- 백테스트 거래 목록 -->
    <div class="section-title">가상 거래 내역 (최근 200건)</div>
    <div id="bt_trade_context" class="mb-2 p-2 rounded" style="font-size:12px;background:var(--bg-secondary, #1c2128);color:var(--muted);display:none"></div>
    <div class="card p-3">
      <div class="table-responsive">
        <table class="table table-hover" id="bt_table">
          <thead><tr>
            <th>종목</th><th>매수시각</th><th>매도시각</th>
            <th>매수가</th><th>매도가</th><th>수량</th>
            <th>손익(원)</th><th>수익률%</th><th>보유(분)</th><th>사유</th><th>진입RSI</th>
          </tr></thead>
          <tbody id="bt_tbody"></tbody>
        </table>
      </div>
    </div>
  </div><!-- tab-backtest end -->

  <!-- ═══ 꼬리잡기 백테스트 탭 ══════════════════════════════════════════════ -->
  <div id="tab-tail" style="display:none">
    <div class="card p-3 mb-3">
      <!-- 기간 -->
      <div class="row g-2 align-items-end mb-2">
        <div class="col-auto">
          <label class="form-label param-row">시작일</label>
          <input type="date" class="form-control" id="tl_start" style="width:150px">
        </div>
        <div class="col-auto">
          <label class="form-label param-row">종료일</label>
          <input type="date" class="form-control" id="tl_end" style="width:150px">
        </div>
        <div class="col-auto">
          <label class="form-label param-row">투자금(원)</label>
          <input type="number" class="form-control" id="tl_slot" value="1000000" min="100000" step="100000" style="width:130px">
        </div>
      </div>
      <!-- 핵심 파라미터 (1행) -->
      <div class="row g-2 align-items-end">
        <div class="col-4 col-md-1">
          <label class="form-label param-row" title="당일 시가→저가 낙폭이 이 값 이상이어야 진입">낙폭필터(%)</label>
          <input type="number" class="form-control" id="tl_drop" value="2.0" min="0.5" max="10" step="0.5">
        </div>
        <div class="col-4 col-md-1">
          <label class="form-label param-row" title="(현재가-저가)/(고가-저가) 최소 회복 비율(%)">회복률(%)</label>
          <input type="number" class="form-control" id="tl_rec" value="40" min="10" max="90" step="5">
        </div>
        <div class="col-4 col-md-1">
          <label class="form-label param-row" title="꼬리길이/몸통길이 최소 비율 (망치봉 강도)">꼬리/몸통</label>
          <input type="number" class="form-control" id="tl_tail" value="1.5" min="0.3" max="5" step="0.1">
        </div>
        <div class="col-4 col-md-1">
          <label class="form-label param-row">손절(%)</label>
          <input type="number" class="form-control" id="tl_sl" value="3.0" min="0.5" max="10" step="0.5">
        </div>
        <div class="col-4 col-md-1">
          <label class="form-label param-row">익절(%)</label>
          <input type="number" class="form-control" id="tl_tp" value="5.0" min="0.5" max="15" step="0.5">
        </div>
        <div class="col-4 col-md-1">
          <label class="form-label param-row" title="매수가 대비 이 값 이상 오른 뒤 어깨 컷 발동">어깨발동(%)</label>
          <input type="number" class="form-control" id="tl_smin" value="1.5" min="0.5" max="5" step="0.5">
        </div>
        <div class="col-4 col-md-1">
          <label class="form-label param-row" title="고점 대비 이 % 하락 시 어깨 컷 매도">어깨컷(%)</label>
          <input type="number" class="form-control" id="tl_scut" value="3.0" min="0.5" max="8" step="0.5">
        </div>
        <div class="col-4 col-md-1">
          <label class="form-label param-row" title="꼬리 길이 최소 비율(%) → TAIL_PCT_MIN">꼬리최소(%)</label>
          <input type="number" class="form-control" id="tl_tail_pct" value="0.3" min="0.01" max="2" step="0.05">
        </div>
        <div class="col-4 col-md-1">
          <label class="form-label param-row" title="3분봉 회복위치 상한(%) → MAX_RECOVERY_RATIO_3M">3분최대회복(%)</label>
          <input type="number" class="form-control" id="tl_max_rec_3m" value="80" min="50" max="100" step="5">
        </div>
        <div class="col-4 col-md-1">
          <label class="form-label param-row" title="고점 대비 이 비율 이상이면 진입 거부(%) → HIGH_PRICE_CHASE_THRESHOLD">고점추격방지(%)</label>
          <input type="number" class="form-control" id="tl_high_chase" value="96" min="90" max="100" step="1">
        </div>
      </div>
      <!-- V3 방어 파라미터 -->
      <div class="row g-2 align-items-end mt-1">
        <div class="col-4 col-md-1">
          <label class="form-label param-row" title="최소 주가">최소가격(원)</label>
          <input type="number" class="form-control" id="tl_min_price" value="1000" min="0" step="500">
        </div>
        <div class="col-4 col-md-1">
          <label class="form-label param-row" title="당일 최고/최저 상승폭 제한">최대급등(%)</label>
          <input type="number" class="form-control" id="tl_max_daily_change" value="20" min="5" max="30" step="1">
        </div>
        <div class="col-4 col-md-1">
          <label class="form-label param-row" title="MA20 대비 가격 이격도 허용 범위">MA20이격(%)</label>
          <input type="number" class="form-control" id="tl_ma20_above" value="3.0" min="1.0" max="10.0" step="0.5">
        </div>
        <div class="col-4 col-md-1">
          <label class="form-label param-row" title="1회 매매 최대 손실 원화 (금액 컷)">최대손실컷(원)</label>
          <input type="number" class="form-control" id="tl_max_loss_krw" value="200000" min="10000" step="10000">
        </div>
        <div class="col-4 col-md-1">
          <label class="form-label param-row" title="금액손실컷 발동 최소 하락률(%) — 이 미만은 흔들림으로 보고 미발동">손실컷최소하락(%)</label>
          <input type="number" class="form-control" id="tl_min_drop_loss_cut" value="1.5" min="0.5" max="5" step="0.5">
        </div>
        <div class="col-4 col-md-1">
          <label class="form-label param-row" title="ATR 기준 손절 배수 (변동성 대응)">ATR손절(배)</label>
          <input type="number" class="form-control" id="tl_stop_atr" value="2.5" min="0.5" max="10" step="0.5">
        </div>
        <div class="col-4 col-md-1">
          <label class="form-label param-row" title="ATR 기준 익절 배수 (변동성 대응)">ATR익절(배)</label>
          <input type="number" class="form-control" id="tl_target_atr" value="8.0" min="1.0" max="20" step="0.5">
        </div>
      </div>
      <!-- 고급 파라미터 (2행) -->
      <div class="row g-2 align-items-end mt-1">
        <div class="col-4 col-md-1">
          <label class="form-label param-row" title="RSI 계산 캔들 수 (실매·파라서치와 동일)">RSI기간</label>
          <input type="number" class="form-control" id="tl_rsi_period" value="14" min="3" max="21" step="1">
        </div>
        <div class="col-4 col-md-1">
          <label class="form-label param-row" title="RSI 이 값 이상이면 과열로 진입 거부">RSI과열기준</label>
          <input type="number" class="form-control" id="tl_rsi" value="78" min="50" max="95" step="1">
        </div>
        <div class="col-4 col-md-1">
          <label class="form-label param-row" title="청산 후 같은 종목 재진입 금지 시간(분)">쿨다운(분)</label>
          <input type="number" class="form-control" id="tl_cool" value="30" min="0" max="120" step="5">
        </div>
        <div class="col-4 col-md-1">
          <label class="form-label param-row">매수시작(HHMM)</label>
          <input type="number" class="form-control" id="tl_ts" value="830" min="900" max="1500" step="100">
        </div>
        <div class="col-4 col-md-1">
          <label class="form-label param-row">매수종료(HHMM)</label>
          <input type="number" class="form-control" id="tl_te" value="1530" min="900" max="1530" step="100">
        </div>
        <div class="col-4 col-md-1">
          <label class="form-label param-row" title="종목당 하루 최대 거래횟수 (실매 MAX_STOCKS)">일일최대매수</label>
          <input type="number" class="form-control" id="tl_maxd" value="3" min="1" max="10">
        </div>
        <div class="col-12 col-md-auto mt-2 d-flex gap-2 flex-wrap">
          <button class="btn btn-primary px-4" onclick="runTailBacktest()">🚀 백테스트 실행</button>
          <button class="btn btn-warning px-3" onclick="runTailParamSearch()">🔍 파라미터탐색</button>
          <button class="btn btn-success px-3" onclick="saveTailConfig()">💾 봇에 설정저장</button>
        </div>
      </div>
      <!-- 파라미터 탐색 결과 -->
      <div id="tail_search_result" style="display:none" class="mt-3">
        <div class="card-title" style="font-size:13px;color:var(--accent)">🔍 파라미터탐색 결과 (수익 기준 정렬)</div>
        <div class="table-responsive">
          <table class="table table-sm table-hover" style="font-size:12px">
            <thead><tr><th>#</th><th>낙폭%</th><th>회복%</th><th>손절%</th><th>익절%</th>
              <th>손익(원)</th><th>승률%</th><th>거래수</th><th>PF</th><th>MDD</th><th></th></tr></thead>
            <tbody id="tail_search_tbody"></tbody>
          </table>
        </div>
      </div>
      <!-- 파라미터 설명 -->
      <div class="mt-3 p-2 rounded" style="background:#0d1117;border:1px solid #21262d;font-size:11px;color:var(--muted)">
        <b style="color:var(--accent)">📖 파라미터 설명 (실매·파라서치와 동일 DB env_config — 화면 값 = 백테에 사용되는 값)</b>
        <div class="row g-1 mt-1">
          <div class="col-12 col-md-6">
            <table style="width:100%;border-collapse:collapse">
              <tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">낙폭필터(%)</td><td>당일 시가→저가 하락폭 기준 → 봇 DB: <b>MIN_DROP_RATE=3%</b></td></tr>
              <tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">회복률(%)</td><td>(현재가-저가)/(고가-저가) 최소 → 봇 DB: <b>MIN_RECOVERY_RATIO_SHORT=50%</b></td></tr>
              <tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">꼬리/몸통</td><td>망치봉 강도 (꼬리÷몸통) → 봇 DB: <b>TAIL_RATIO_MIN=1.5</b></td></tr>
              <tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">손절(%)</td><td>매수가 대비 하락 → 봇 DB: <b>STOP_LOSS_PCT=-3%</b></td></tr>
              <tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">익절(%)</td><td>매수가 대비 상승 → 봇 DB: <b>TAKE_PROFIT_PCT=5%</b></td></tr>
            </table>
          </div>
          <div class="col-12 col-md-6">
            <table style="width:100%;border-collapse:collapse">
              <tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">어깨발동(%)</td><td>수익이 이 값 이상일 때 어깨 컷 활성 → 봇 DB: <b>SHOULDER_MIN_HIGH_PCT=1.5%</b></td></tr>
              <tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">어깨컷(%)</td><td>고점 대비 이 값 하락 시 매도 → 봇 DB: <b>SHOULDER_CUT_PCT=3%</b></td></tr>
              <tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">꼬리최소(%)</td><td>꼬리 길이 최소 비율 → 봇 DB: <b>TAIL_PCT_MIN</b></td></tr>
              <tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">3분최대회복(%)</td><td>3분봉 회복위치 상한 → 봇 DB: <b>MAX_RECOVERY_RATIO_3M</b></td></tr>
              <tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">고점추격방지(%)</td><td>고점 대비 이 비율 이상이면 진입 거부 → 봇 DB: <b>HIGH_PRICE_CHASE_THRESHOLD</b></td></tr>
              <tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">RSI과열기준</td><td>RSI 이상이면 진입 거부 → 봇 DB: <b>RSI_OVERHEAT_THRESHOLD=78</b></td></tr>
              <tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">쿨다운(분)</td><td>청산 후 재진입 금지 → 봇 DB: <b>REENTRY_COOLDOWN_SEC</b> (초→분)</td></tr>
            </table>
          </div>
        </div>
        <div class="mt-1">⚡ 진입가 = <b>신호봉(3분봉) 다음 봉 시가</b> | 수수료 0.015%×2 + 거래세 0.18% 자동 차감 | 데이터: ws_candles 3분봉</div>
        <div class="mt-1" style="color:var(--muted)">📌 웹 백테와 파라미터서치 결과를 비교하려면 <b>시작일/종료일</b>과 <b>매수시작/매수종료</b>를 동일하게 두세요. 파라서치는 DB 기준(예: 930–1500)을 쓰므로, 비교 시 웹에서도 0930·1500으로 맞추면 거래 수·손익이 비슷해집니다.</div>
      </div>
    </div>

    <!-- 파라미터 요약 바 -->
    <div id="tl_params_bar" class="text-muted mb-3" style="font-size:12px;display:none"></div>

    <!-- 요약 카드 -->
    <div class="row g-3 mb-3">
      <div class="col-6 col-md-3 col-lg-2"><div class="card p-3">
        <div class="card-title">총 거래</div><div class="stat-val" id="tl_total">-</div>
      </div></div>
      <div class="col-6 col-md-3 col-lg-2"><div class="card p-3">
        <div class="card-title">승률</div><div class="stat-val" id="tl_winrate">-</div>
      </div></div>
      <div class="col-6 col-md-3 col-lg-2"><div class="card p-3">
        <div class="card-title">순손익(수수료·세금 포함)</div><div class="stat-val" id="tl_pnl">-</div>
      </div></div>
      <div class="col-6 col-md-3 col-lg-2"><div class="card p-3">
        <div class="card-title">Profit Factor</div><div class="stat-val" id="tl_pf">-</div>
      </div></div>
      <div class="col-6 col-md-3 col-lg-2"><div class="card p-3">
        <div class="card-title">최대 낙폭(MDD)</div><div class="stat-val red" id="tl_mdd">-</div>
      </div></div>
      <div class="col-6 col-md-3 col-lg-2"><div class="card p-3">
        <div class="card-title">평균 보유(분)</div><div class="stat-val" id="tl_hold">-</div>
      </div></div>
    </div>
    <!-- 승/패 바 -->
    <div class="card p-3 mb-3" id="tl_winbar_card" style="display:none">
      <div class="d-flex justify-content-between mb-1">
        <span style="color:var(--green)" id="tl_win_label">-</span>
        <span style="color:var(--red)"   id="tl_loss_label">-</span>
      </div>
      <div class="ratio-bar">
        <span class="ratio-fill-g" id="tl_ratio_g" style="width:50%"></span>
        <span class="ratio-fill-r" id="tl_ratio_r" style="width:50%"></span>
      </div>
    </div>

    <div class="row g-3">
      <div class="col-12 col-lg-8">
        <div class="card p-3">
          <div class="card-title mb-2">누적 손익 곡선 (가상)</div>
          <canvas id="tl_equity_chart" height="220"></canvas>
        </div>
      </div>
      <div class="col-12 col-lg-4">
        <div class="card p-3">
          <div class="card-title mb-2">매도 이유 분포</div>
          <canvas id="tl_reason_chart" height="200"></canvas>
        </div>
      </div>
    </div>

    <div class="card p-3 mt-3">
      <div class="card-title mb-2">일별 손익 (가상)</div>
      <canvas id="tl_daily_chart" height="150"></canvas>
    </div>

    <!-- 거래 내역 테이블 -->
    <div class="section-title">가상 거래 내역 (최근 200건)</div>
    <div id="tl_trade_context" class="mb-2 p-2 rounded" style="font-size:12px;background:var(--bg-secondary, #1c2128);color:var(--muted);display:none"></div>
    <div class="card p-3">
      <div class="table-responsive">
        <table class="table table-hover" id="tl_table">
          <thead><tr>
            <th>종목</th><th>매수시각</th><th>매도시각</th>
            <th>매수가</th><th>매도가</th><th>수량</th>
            <th>손익(원)</th><th>보유(분)</th><th>매도사유</th>
          </tr></thead>
          <tbody id="tl_tbody"></tbody>
        </table>
      </div>
    </div>
  </div><!-- tab-tail end -->

  <!-- ═══ 홀딩 전략 탭 ═══════════════════════════════════════════════════════ -->
  <div id="tab-holding" style="display:none">

    <!-- 공통 기간 -->
    <div class="card p-3 mb-3">
      <div class="row g-2 align-items-end">
        <div class="col-auto">
          <label class="form-label param-row">백테스트 시작일</label>
          <input type="date" class="form-control" id="hd_start" style="width:150px">
        </div>
        <div class="col-auto">
          <label class="form-label param-row">종료일</label>
          <input type="date" class="form-control" id="hd_end" style="width:150px">
        </div>
        <div class="col-auto">
          <label class="form-label param-row">캔들 수집 시작일</label>
          <input type="date" class="form-control" id="hd_fetch_start" style="width:150px">
        </div>
        <div class="col-auto mt-auto">
          <button class="btn btn-secondary" onclick="hdLoadStocks()">🔄 종목 목록 갱신</button>
        </div>
      </div>
      <div class="mt-2" style="font-size:11px;color:var(--muted)">
        ⚡ 일봉 기반 전략 | RSI 3단계 분할매수 (35/30/25) | 수익률+익절 / RSI과열 / 손절 | <code>holding_candles</code> DB 사용
      </div>
    </div>

    <!-- 종목 카드 목록 (JS로 동적 생성) -->
    <div id="hd_stock_list" class="row g-3"></div>

    <!-- 백테스트 결과 영역 (선택한 종목) -->
    <div id="hd_result_area" style="display:none" class="mt-4">
      <div class="section-title" id="hd_result_title">백테스트 결과</div>
      <div class="row g-3 mb-3">
        <div class="col-6 col-md-2"><div class="card p-3">
          <div class="card-title">총 거래</div><div class="stat-val" id="hd_total">-</div>
        </div></div>
        <div class="col-6 col-md-2"><div class="card p-3">
          <div class="card-title">승률</div><div class="stat-val" id="hd_wr">-</div>
        </div></div>
        <div class="col-6 col-md-2"><div class="card p-3">
          <div class="card-title">순손익</div><div class="stat-val" id="hd_pnl">-</div>
        </div></div>
        <div class="col-6 col-md-2"><div class="card p-3">
          <div class="card-title">Profit Factor</div><div class="stat-val" id="hd_pf">-</div>
        </div></div>
        <div class="col-6 col-md-2"><div class="card p-3">
          <div class="card-title">최대 낙폭(MDD)</div><div class="stat-val red" id="hd_mdd">-</div>
        </div></div>
        <div class="col-6 col-md-2"><div class="card p-3">
          <div class="card-title">평균 보유(일)</div><div class="stat-val" id="hd_hold">-</div>
        </div></div>
      </div>
      <!-- Buy & Hold 비교 -->
      <div class="row g-3 mb-3" id="hd_bnh_row" style="display:none">
        <div class="col-12"><div class="card p-3" style="border-color:var(--accent);background:rgba(88,166,255,0.05)">
          <div class="card-title" style="color:var(--accent)">📊 Buy &amp; Hold 벤치마크 비교 <small class="text-muted">("가만히 들고 있었으면?")</small></div>
          <div class="row g-3 mt-1">
            <div class="col-6 col-md-3"><div class="card-title">봇 수익률(투자금 대비)</div><div class="stat-val" id="hd_bot_pct">-</div></div>
            <div class="col-6 col-md-3"><div class="card-title">Buy &amp; Hold 수익률</div><div class="stat-val" id="hd_bnh_pct">-</div></div>
            <div class="col-6 col-md-3"><div class="card-title">Buy &amp; Hold 손익(원)</div><div class="stat-val" id="hd_bnh_pnl">-</div></div>
            <div class="col-6 col-md-3"><div class="card-title">알파(봇 - B&amp;H)</div><div class="stat-val" id="hd_alpha">-</div></div>
          </div>
        </div></div>
      </div>
      <div class="row g-3 mb-3">
        <div class="col-12 col-lg-8"><div class="card p-3">
          <div class="card-title mb-2">누적 손익 곡선</div>
          <canvas id="hd_equity_chart" height="220"></canvas>
        </div></div>
        <div class="col-12 col-lg-4"><div class="card p-3">
          <div class="card-title mb-2">매도 이유 분포</div>
          <canvas id="hd_reason_chart" height="200"></canvas>
        </div></div>
      </div>
      <!-- 파라미터 탐색 결과 -->
      <div id="hd_search_result" style="display:none">
        <div class="section-title">🔍 파라미터 탐색 결과 (TOP 20)</div>
        <div class="card p-3">
          <div class="table-responsive">
            <table class="table table-hover" style="font-size:12px">
              <thead id="hd_search_thead"><tr>
                <th>-</th><th>손익(원)</th><th>승률</th><th>거래</th><th>PF</th><th>보유(일)</th><th>적용</th>
              </tr></thead>
              <tbody id="hd_search_tbody"></tbody>
            </table>
          </div>
        </div>
      </div>
      <!-- 거래 내역 -->
      <div class="section-title mt-3">가상 거래 내역 (최근 200건)</div>
      <div class="card p-3">
        <div class="table-responsive">
          <table class="table table-hover" style="font-size:12px">
            <thead><tr>
              <th>매수일</th><th>매도일</th><th>평단가</th><th>매도가</th>
              <th>수량</th><th>손익(원)</th><th>보유(일)</th><th>매도사유</th>
            </tr></thead>
            <tbody id="hd_trade_tbody"></tbody>
          </table>
        </div>
      </div>
    </div>
  </div><!-- tab-holding end -->

</div><!-- container end -->

<script>
// ────────────────────────────────────────────
// 유틸
// ────────────────────────────────────────────
const $ = id => document.getElementById(id);
const fmt = n => n == null ? '-' : Number(n).toLocaleString('ko-KR');
const fmtPct = n => n == null ? '-' : (n >= 0 ? '+' : '') + Number(n).toFixed(2) + '%';
const colorPnl = (el, val) => {
  el.classList.remove('green','red');
  if (val > 0) el.classList.add('green');
  else if (val < 0) el.classList.add('red');
};

// 차트 인스턴스 저장 (재생성용)
const charts = {};
function destroyChart(id) {
  if (charts[id]) { charts[id].destroy(); delete charts[id]; }
}

const CHART_COLORS = {
  blue:   '#58a6ff', green: '#3fb950', red: '#f85149',
  yellow: '#d29922', purple:'#bc8cff', orange:'#f0883e',
};

function lineChart(id, labels, data, label, color) {
  destroyChart(id);
  const ctx = $(id).getContext('2d');
  charts[id] = new Chart(ctx, {
    type: 'line',
    data: {
      labels,
      datasets: [{
        label, data,
        borderColor: color || CHART_COLORS.blue,
        borderWidth: 2,
        pointRadius: data.length > 100 ? 0 : 3,
        fill: { target: 'origin', above: 'rgba(63,185,80,0.08)', below: 'rgba(248,81,73,0.08)' },
        tension: 0.3,
      }]
    },
    options: {
      responsive: true, animation: false,
      plugins: { legend: { display: false },
                 tooltip: { callbacks: { label: ctx => fmt(ctx.parsed.y) + '원' } } },
      scales: {
        x: { ticks: { color: '#8b949e', maxTicksLimit: 12 }, grid: { color:'#21262d' } },
        y: { ticks: { color: '#8b949e', callback: v => fmt(v) }, grid: { color:'#21262d' } },
      }
    }
  });
}

function barChart(id, labels, data, colors) {
  destroyChart(id);
  const ctx = $(id).getContext('2d');
  charts[id] = new Chart(ctx, {
    type: 'bar',
    data: {
      labels,
      datasets: [{
        data,
        backgroundColor: colors || data.map(v => v >= 0 ? 'rgba(63,185,80,0.7)' : 'rgba(248,81,73,0.7)'),
      }]
    },
    options: {
      responsive: true, animation: false,
      plugins: { legend: { display: false },
                 tooltip: { callbacks: { label: ctx => fmt(ctx.parsed.y) + '원' } } },
      scales: {
        x: { ticks: { color: '#8b949e', maxTicksLimit: 14 }, grid: { color:'#21262d' } },
        y: { ticks: { color: '#8b949e', callback: v => fmt(v) }, grid: { color:'#21262d' } },
      }
    }
  });
}

function doughnutChart(id, labels, data) {
  destroyChart(id);
  const ctx = $(id).getContext('2d');
  const palette = [CHART_COLORS.green, CHART_COLORS.red, CHART_COLORS.yellow,
                   CHART_COLORS.purple, CHART_COLORS.orange, CHART_COLORS.blue];
  charts[id] = new Chart(ctx, {
    type: 'doughnut',
    data: {
      labels,
      datasets: [{ data, backgroundColor: palette, borderWidth: 0 }]
    },
    options: {
      responsive: true, animation: false,
      plugins: { legend: { position: 'right', labels: { color:'#8b949e', font:{ size:11 } } } }
    }
  });
}

// ────────────────────────────────────────────
// 탭 전환
// ────────────────────────────────────────────
document.querySelectorAll('[data-tab]').forEach(el => {
  el.addEventListener('click', e => {
    e.preventDefault();
    document.querySelectorAll('[data-tab]').forEach(x => x.classList.remove('active'));
    el.classList.add('active');
    const tab = el.dataset.tab;
    $('tab-actual').style.display   = tab === 'actual'   ? '' : 'none';
    $('tab-backtest').style.display = tab === 'backtest' ? '' : 'none';
    $('tab-tail').style.display     = tab === 'tail'     ? '' : 'none';
    $('tab-holding').style.display  = tab === 'holding'  ? '' : 'none';
    if (tab === 'holding') hdLoadStocks();
  });
});

// ────────────────────────────────────────────
// 날짜 기본값 설정
// ────────────────────────────────────────────
(function() {
  const today = new Date();
  const fmt = d => d.toISOString().slice(0, 10);
  const monthAgo = new Date(today); monthAgo.setMonth(monthAgo.getMonth() - 1);
  $('act_start').value = fmt(monthAgo);
  $('act_end').value   = fmt(today);
  $('bt_start').value  = fmt(monthAgo);
  $('bt_end').value    = fmt(today);
  $('tl_start').value       = fmt(monthAgo);
  $('tl_end').value         = fmt(today);
  const twoYearsAgo = new Date(today); twoYearsAgo.setFullYear(twoYearsAgo.getFullYear() - 2);
  $('hd_start').value       = fmt(twoYearsAgo);
  $('hd_end').value         = fmt(today);
  $('hd_fetch_start').value = fmt(twoYearsAgo);
  // 캔들 수집 시작일 변경 시 → 백테스트 시작일도 자동 연동
  $('hd_fetch_start').addEventListener('change', function() {
    if (this.value) $('hd_start').value = this.value;
  });
  $('lastRefresh').textContent = '업데이트: ' + today.toLocaleString('ko-KR');

  // ── DB 최신 env_config → 스캘핑·꼬리잡기 초기값 자동 로드 ──────────────
  // DB 값이 있으면 반영, null이면 HTML에 하드코딩된 기본값 그대로 유지
  fetch('/api/env/params')
    .then(r => r.json())
    .then(d => {
      const set = (id, val) => { if (val !== null && val !== undefined) $(id).value = val; };
      // 스캘핑 탭
      const s = d.scalp || {};
      set('bt_rsi_oversold', s.rsi_oversold);
      set('bt_rsi_overbought', s.rsi_overbought);
      set('bt_sl',           s.sl_pct);
      set('bt_tp',           s.tp_pct);
      set('bt_drop',         s.drop_rate);
      set('bt_trail_trig',   s.trail_trigger);
      set('bt_cooldown',     s.cooldown_min);
      if (s.slot_money) set('bt_slot', s.slot_money);
      if (s.high_chase_thr != null) set('bt_high_chase', s.high_chase_thr);
      if (s.max_daily_chg != null) set('bt_max_daily_chg', s.max_daily_chg);
      if (s.min_price != null) set('bt_min_price', s.min_price);
      if (s.max_loss_krw != null) set('bt_max_loss_krw', s.max_loss_krw);
      if (s.min_margin != null) set('bt_min_margin', s.min_margin);
      if (s.use_defense_filters !== undefined && $('bt_use_defense')) {
        const v = String(s.use_defense_filters).trim().toLowerCase();
        $('bt_use_defense').checked = (v === '1' || v === 'true' || v === 'y' || v === 'yes' || v === 'on');
      }

      // 꼬리잡기 탭 — DB(env_config)와 동일 값으로 채움 (실매·파라서치와 동일)
      const t = d.tail || {};
      set('tl_drop', t.drop);
      set('tl_rec',  t.rec);
      set('tl_tail', t.tail_ratio);
      set('tl_sl',   t.sl_pct);
      set('tl_tp',   t.tp_pct);
      set('tl_smin', t.smin);
      set('tl_scut', t.scut);
      set('tl_cool', t.cool);
      set('tl_rsi',  t.rsi);
      set('tl_ts',   t.time_start);
      set('tl_te',   t.time_end);
      set('tl_maxd', t.max_daily);
      set('tl_rsi_period', t.rsi_period);
      set('tl_tail_pct', t.tail_pct_min);
      set('tl_max_rec_3m', t.max_rec_3m);
      set('tl_high_chase', t.high_chase);
      // V3 방어 파라미터
      set('tl_min_price', t.min_price);
      set('tl_max_daily_change', t.max_daily_change);
      set('tl_ma20_above', t.ma20_max_above);
      set('tl_max_loss_krw', t.max_loss_krw);
      if (t.min_drop_pct_for_loss_cut != null) set('tl_min_drop_loss_cut', t.min_drop_pct_for_loss_cut);
      set('tl_stop_atr', t.stop_atr_mult);
      set('tl_target_atr', t.target_atr_mult);
    })
    .catch(() => { /* DB 연결 실패 시 HTML 기본값 유지 */ });

  const defChk = $('bt_use_defense');
  if (defChk) {
    defChk.addEventListener('change', saveScalpDefenseRealtime);
  }
})();

// ────────────────────────────────────────────
// 실거래 분석 로드
// ────────────────────────────────────────────
function loadActual() {
  const strategy = document.querySelector('input[name=act_strategy]:checked').value;
  const start = $('act_start').value;
  const end   = $('act_end').value;
  showSpinner(true);
  fetch(`/api/actual?strategy=${strategy}&start=${start}&end=${end}`)
    .then(r => r.json())
    .then(d => {
      showSpinner(false);
      renderActual(d);
    })
    .catch(err => { showSpinner(false); alert('오류: ' + err); });
}

function renderActual(d) {
  const s = d.summary;
  const filtered = Number((d.meta || {}).filtered_forced_rows || 0);

  $('a_total').textContent   = fmt(s.total_trades) + '건';
  $('a_winrate').textContent = s.win_rate + '%';
  colorPnl($('a_winrate'), s.win_rate - 50);

  $('a_pnl').textContent = fmt(s.total_pnl) + '원';
  colorPnl($('a_pnl'), s.total_pnl);

  $('a_pf').textContent  = s.profit_factor >= 999 ? '∞' : s.profit_factor;
  colorPnl($('a_pf'), s.profit_factor - 1);

  $('a_mdd').textContent  = '-' + fmt(s.max_drawdown) + '원';
  $('a_hold').textContent = s.avg_hold_min + '분';
  const note = $('act_filter_note');
  if (note) {
    if (filtered > 0) {
      note.style.display = '';
      note.innerHTML = `⚠️ 집계 제외: 0원 강제정리 <b>${filtered}</b>건 (잔고 동기화 레코드)`;
    } else {
      note.style.display = 'none';
      note.innerHTML = '';
    }
  }
  if (filtered > 0) {
    console.log(`[실거래 분석] 0원 강제정리 ${filtered}건 제외 후 집계`);
  }

  // 승/패 바
  if (s.total_trades > 0) {
    $('act_winbar_card').style.display = '';
    const wr = s.win_rate;
    $('a_win_label').textContent  = `🟢 승 ${s.win_trades}건 (${wr}%)`;
    $('a_loss_label').textContent = `🔴 패 ${s.loss_trades}건 (${(100-wr).toFixed(1)}%)`;
    $('a_ratio_g').style.width = wr + '%';
    $('a_ratio_r').style.width = (100 - wr) + '%';
  }

  // 누적 손익 곡선
  const eqLabels = d.equity.map((e,i) => i % Math.max(1, Math.floor(d.equity.length/20)) === 0 ? e.date : '');
  lineChart('a_equity_chart', d.equity.map(e => e.date), d.equity.map(e => e.cum_pnl), '누적손익');

  // 매도이유 도넛
  const rKeys = Object.keys(d.reasons);
  doughnutChart('a_reason_chart', rKeys, rKeys.map(k => d.reasons[k]));

  // 종목별 손익 바
  barChart('a_code_chart',
    d.top_codes.map(c => c.name || c.code),
    d.top_codes.map(c => c.pnl));

  // 일별 바
  barChart('a_daily_chart',
    d.daily.map(e => e.date.slice(5)),
    d.daily.map(e => e.pnl));

  // 테이블
  const tbody = $('act_tbody');
  tbody.innerHTML = '';
  const rows = [...d.trades].reverse();
  rows.forEach(t => {
    const pnl  = Number(t.realized_pnl || 0);
    const rate = Number(t.profit_rate || 0);
    const badge = pnl > 0 ? 'badge-win' : (pnl < 0 ? 'badge-loss' : 'badge-flat');
    const pnlCls = pnl > 0 ? 'text-pnl-pos' : (pnl < 0 ? 'text-pnl-neg' : '');
    tbody.insertAdjacentHTML('beforeend', `
      <tr>
        <td>${(t.sell_date||'').slice(0,16)}</td>
        <td>${t.name||''}<br><span style="color:var(--muted);font-size:11px">${t.code}</span></td>
        <td>${fmt(t.buy_price)}</td>
        <td>${fmt(t.sell_price)}</td>
        <td>${fmt(t.qty)}</td>
        <td class="${pnlCls}">${fmt(pnl)}</td>
        <td><span class="${badge}">${fmtPct(rate)}</span></td>
        <td>${t.hold_minutes||0}</td>
        <td style="font-size:11px">${t.sell_reason||''}</td>
      </tr>`);
  });
}

// ────────────────────────────────────────────
// 백테스트 실행
// ────────────────────────────────────────────
// ── 스캘핑 파라미터탐색 ──────────────────────────────────────────────────────
function runScalpParamSearch() {
  const start = $('bt_start').value;
  const end   = $('bt_end').value;
  if (!start) { alert('시작일을 입력하세요'); return; }
  // 그리드에 없는 파라미터를 현재 UI 값으로 고정 (rsi_period, cooldown 등)
  const base = new URLSearchParams({
    start, end, top: 10, min_trades: 3,
    rsi_period:  $('bt_rsi_period').value,
    rsi_overbought: $('bt_rsi_overbought')?.value || 75,
    slot_money:  $('bt_slot').value,
    cooldown_min: $('bt_cooldown').value,
    vol_mult:    $('bt_vol_mult').value,
    trail_trigger: $('bt_trail_trig').value,
    trail_stop:  $('bt_trail_stop').value,
    time_start:  $('bt_time_start').value,
    time_end:    $('bt_time_end').value,
    max_daily:   $('bt_max_daily').value,
    high_chase_thr:  $('bt_high_chase').value,
    max_daily_chg:   $('bt_max_daily_chg').value,
    min_price:       $('bt_min_price').value,
    max_loss_krw:    $('bt_max_loss_krw').value,
    min_margin:      $('bt_min_margin').value,
    use_defense_filters: $('bt_use_defense')?.checked ? 1 : 0,
  }).toString();
  showSpinner(true);
  fetch('/api/backtest/scalping/param_search?' + base)
    .then(r => r.json())
    .then(d => {
      showSpinner(false);
      if (d.error) { alert('❌ ' + d.error); return; }
      const res = d.results || [];
      const tbody = $('scalp_search_tbody');
      tbody.innerHTML = '';
      if (!res.length) { tbody.innerHTML = '<tr><td colspan="11" class="text-center text-muted">결과 없음 (기간/봉 부족)</td></tr>'; }
      res.forEach((r, i) => {
        const p = r.params;
        tbody.insertAdjacentHTML('beforeend', `<tr>
          <td>${i+1}</td>
          <td>${p.rsi_oversold}</td><td>${p.rsi_overbought ?? 75}</td><td>${p.sl_pct}</td><td>${p.tp_pct}</td><td>${p.drop_rate}</td>
          <td><span class="badge ${String(p.use_defense_filters) === '0' ? 'badge-secondary' : 'badge-info'}">${String(p.use_defense_filters) === '0' ? 'OFF' : 'ON'}</span></td>
          <td style="color:${r.total_pnl>=0?'var(--green)':'var(--red)'}">${r.total_pnl?.toLocaleString()}</td>
          <td>${r.win_rate?.toFixed(1)}%</td><td>${r.total_trades}</td>
          <td>${r.pf?.toFixed(2)}</td><td>${Math.round(r.mdd)?.toLocaleString()}</td>
          <td><button class="btn btn-xs btn-outline-info" style="font-size:10px;padding:1px 6px"
            onclick="applyScalpParams(${p.rsi_oversold},${p.rsi_overbought ?? 75},${p.sl_pct},${p.tp_pct},${p.drop_rate})">적용</button></td>
        </tr>`);
      });
      $('scalp_search_result').style.display = '';
      console.log(`[스캘핑 탐색] 조합 ${d.total_combos}개 / 유효 결과 ${res.length}개`);
    })
    .catch(e => { showSpinner(false); alert('오류: ' + e); });
}

function applyScalpParams(rsi_oversold, rsi_overbought, sl_pct, tp_pct, drop_rate) {
  if (arguments.length === 4) { drop_rate = tp_pct; tp_pct = sl_pct; sl_pct = rsi_overbought; rsi_overbought = 75; }
  $('bt_rsi_oversold').value = rsi_oversold;
  if ($('bt_rsi_overbought')) $('bt_rsi_overbought').value = rsi_overbought;
  $('bt_sl').value = sl_pct;
  $('bt_tp').value = tp_pct;
  $('bt_drop').value = drop_rate;
  alert(`✅ 파라미터 적용 완료\nRSI과매도:${rsi_oversold} / RSI과열:${rsi_overbought} / 손절:${sl_pct}% / 익절:${tp_pct}% / 낙폭:${drop_rate}%\n\n백테스트를 다시 실행하세요.`);
}

function saveScalpConfig() {
  const body = {
    rsi_oversold:  parseFloat($('bt_rsi_oversold').value),
    rsi_overbought: parseFloat($('bt_rsi_overbought')?.value || 75),
    sl_pct:        parseFloat($('bt_sl').value),
    tp_pct:        parseFloat($('bt_tp').value),
    drop_rate:     parseFloat($('bt_drop').value),
    trail_trigger: parseFloat($('bt_trail_trig').value),
    cooldown_min:  parseFloat($('bt_cooldown').value),
    high_chase_thr:  parseFloat($('bt_high_chase').value),
    max_daily_chg:   parseFloat($('bt_max_daily_chg').value),
    min_price:       parseFloat($('bt_min_price').value),
    max_loss_krw:    parseFloat($('bt_max_loss_krw').value),
    min_margin:      parseFloat($('bt_min_margin').value),
    use_defense_filters: !!($('bt_use_defense')?.checked),
  };
  if (!confirm(`💾 스캘핑 봇에 아래 파라미터를 저장합니까?\n\n` +
    `RSI과매도: ${body.rsi_oversold} / RSI과열: ${body.rsi_overbought}\n손절: ${body.sl_pct}%\n익절: ${body.tp_pct}%\n낙폭필터: ${body.drop_rate}%\n` +
    `\n⚠️ 봇이 실행 중이면 다음 루프부터 즉시 반영됩니다.`)) return;
  fetch('/api/backtest/scalping/save_config', {
    method: 'POST', headers: {'Content-Type': 'application/json'},
    body: JSON.stringify(body),
  }).then(r => r.json()).then(d => {
    if (d.error) { alert('❌ ' + d.error); return; }
    alert(`✅ 저장 완료 (env_id: ${d.env_id})\n저장 키: ${d.saved_keys?.join(', ')}`);
  }).catch(e => alert('오류: ' + e));
}

// 방어 ON/OFF 토글은 클릭 즉시 DB 저장 (실시간 반영)
async function saveScalpDefenseRealtime() {
  const enabled = !!($('bt_use_defense')?.checked);
  try {
    const r = await fetch('/api/backtest/scalping/save_config', {
      method: 'POST',
      headers: {'Content-Type': 'application/json'},
      body: JSON.stringify({ use_defense_filters: enabled }),
    });
    const d = await r.json();
    if (d.error) {
      alert('❌ 방어로직 저장 실패: ' + d.error);
      return;
    }
    // 현재 값으로 즉시 백테스트 재실행해서 카드/요약도 동기화
    runBacktest();
  } catch (e) {
    alert('오류: ' + e);
  }
}

// ── 꼬리잡기 파라미터탐색 ────────────────────────────────────────────────────
function runTailParamSearch() {
  const start = $('tl_start').value;
  const end   = $('tl_end').value;
  if (!start) { alert('시작일을 입력하세요'); return; }
  // 그리드에 없는 파라미터를 현재 UI 값으로 고정 (tail_ratio_min, shoulder 등)
  const base = new URLSearchParams({
    start, end, top: 10, min_trades: 3,
    slot_money:        $('tl_slot').value,
    tail_ratio_min:    $('tl_tail').value,
    shoulder_min_high: $('tl_smin').value,
    shoulder_cut_pct:  $('tl_scut').value,
    rsi_threshold:     $('tl_rsi').value,
    cooldown_min:      $('tl_cool').value,
    time_start:        $('tl_ts').value,
    time_end:          $('tl_te').value,
    max_daily:         $('tl_maxd').value,
    tail_pct_min:      $('tl_tail_pct').value,
    max_rec_3m:        $('tl_max_rec_3m').value,
    high_chase_thr:    $('tl_high_chase').value,
    min_price:         $('tl_min_price').value,
    max_daily_change:  $('tl_max_daily_change').value,
    ma20_max_above:    $('tl_ma20_above').value,
    max_loss_krw:      $('tl_max_loss_krw').value,
    min_drop_pct_for_loss_cut: parseFloat($('tl_min_drop_loss_cut').value) / 100 || 0.015,
    stop_atr_mult:     $('tl_stop_atr').value,
    target_atr_mult:   $('tl_target_atr').value,
  }).toString();
  showSpinner(true);
  fetch('/api/backtest/tail/param_search?' + base)
    .then(r => r.json())
    .then(d => {
      showSpinner(false);
      if (d.error) { alert('❌ ' + d.error); return; }
      const res = d.results || [];
      const tbody = $('tail_search_tbody');
      tbody.innerHTML = '';
      if (!res.length) { tbody.innerHTML = '<tr><td colspan="11" class="text-center text-muted">결과 없음 (기간/봉 부족)</td></tr>'; }
      res.forEach((r, i) => {
        const p = r.params;
        tbody.insertAdjacentHTML('beforeend', `<tr>
          <td>${i+1}</td>
          <td>${p.min_drop_rate}</td><td>${p.min_recovery_ratio}</td>
          <td>${p.sl_pct}</td><td>${p.tp_pct}</td>
          <td style="color:${r.total_pnl>=0?'var(--green)':'var(--red)'}">${r.total_pnl?.toLocaleString()}</td>
          <td>${r.win_rate?.toFixed(1)}%</td><td>${r.total_trades}</td>
          <td>${r.pf?.toFixed(2)}</td><td>${Math.round(r.mdd)?.toLocaleString()}</td>
          <td><button class="btn btn-xs btn-outline-info" style="font-size:10px;padding:1px 6px"
            onclick="applyTailParams(${p.min_drop_rate},${p.min_recovery_ratio},${p.sl_pct},${p.tp_pct})">적용</button></td>
        </tr>`);
      });
      $('tail_search_result').style.display = '';
      console.log(`[꼬리잡기 탐색] 조합 ${d.total_combos}개 / 유효 결과 ${res.length}개`);
    })
    .catch(e => { showSpinner(false); alert('오류: ' + e); });
}

function applyTailParams(drop, rec, sl, tp) {
  $('tl_drop').value = drop;
  $('tl_rec').value  = rec;
  $('tl_sl').value   = sl;
  $('tl_tp').value   = tp;
  alert(`✅ 파라미터 적용 완료\n낙폭:${drop}% / 회복:${rec}% / 손절:${sl}% / 익절:${tp}%\n\n백테스트를 다시 실행하세요.`);
}

function saveTailConfig() {
  const body = {
    min_drop_rate:      parseFloat($('tl_drop').value),
    min_recovery_ratio: parseFloat($('tl_rec').value),
    tail_ratio_min:     parseFloat($('tl_tail').value),
    tail_pct_min:       parseFloat($('tl_tail_pct').value),
    max_rec_3m:         parseFloat($('tl_max_rec_3m').value),
    sl_pct:             parseFloat($('tl_sl').value),
    tp_pct:             parseFloat($('tl_tp').value),
    shoulder_cut_pct:   parseFloat($('tl_scut').value),
    shoulder_min_high:  parseFloat($('tl_smin').value),
    high_chase_thr:     parseFloat($('tl_high_chase').value),
    cooldown_min:       parseFloat($('tl_cool').value),
    rsi_threshold:      parseFloat($('tl_rsi').value),
    rsi_period:         parseInt($('tl_rsi_period').value, 10),
    time_start:         parseInt($('tl_ts').value, 10),
    time_end:           parseInt($('tl_te').value, 10),
    max_daily:          parseInt($('tl_maxd').value, 10),
    min_price:          parseFloat($('tl_min_price').value),
    max_daily_change:   parseFloat($('tl_max_daily_change').value),
    ma20_max_above:     parseFloat($('tl_ma20_above').value),
    max_loss_krw:       parseInt($('tl_max_loss_krw').value, 10),
    min_drop_pct_for_loss_cut: parseFloat($('tl_min_drop_loss_cut').value) || 1.5,  // % 단위로 전송 (1.5)
    stop_atr_mult:      parseFloat($('tl_stop_atr').value),
    target_atr_mult:    parseFloat($('tl_target_atr').value),
  };
  if (!confirm(`💾 꼬리잡기 봇(실매·백테와 동일 DB)에 아래 파라미터를 저장합니까?\n\n` +
    `낙폭: ${body.min_drop_rate}% | 회복: ${body.min_recovery_ratio}% | 꼬리/몸통: ${body.tail_ratio_min} | 꼬리최소: ${body.tail_pct_min}%\n` +
    `손절: ${body.sl_pct}% | 익절: ${body.tp_pct}% | 어깨컷: ${body.shoulder_cut_pct}% | 3분최대회복: ${body.max_rec_3m}% | 고점추격방지: ${body.high_chase_thr}%\n` +
    `최소가격: ${body.min_price}원 | 최대급등: ${body.max_daily_change}% | MA20이격: ${body.ma20_max_above}% | 최대손실: ${body.max_loss_krw}원\n` +
    `ATR손절: ${body.stop_atr_mult}배 | ATR익절: ${body.target_atr_mult}배\n\n` +
    `⚠️ STOP_LOSS_PCT는 음수로 저장됩니다. 봇 실행 중이면 다음 루프부터 반영됩니다.`)) return;
  fetch('/api/backtest/tail/save_config', {
    method: 'POST', headers: {'Content-Type': 'application/json'},
    body: JSON.stringify(body),
  }).then(r => r.json()).then(d => {
    if (d.error) { alert('❌ ' + d.error); return; }
    alert(`✅ 저장 완료 (env_id: ${d.env_id})\n저장 키: ${d.saved_keys?.join(', ')}`);
  }).catch(e => alert('오류: ' + e));
}

// ─────────────────────────────────────────────────────────────────────────────
function runBacktest() {
  const params = {
    start:        $('bt_start').value,
    end:          $('bt_end').value,
    rsi_period:   $('bt_rsi_period').value,
    rsi_oversold: $('bt_rsi_oversold').value,
    rsi_overbought: $('bt_rsi_overbought').value,
    sl_pct:       $('bt_sl').value,
    tp_pct:       $('bt_tp').value,
    drop_rate:    $('bt_drop').value,
    slot_money:   $('bt_slot').value,
    cooldown_min: $('bt_cooldown').value,
    vol_mult:     $('bt_vol_mult').value,
    trail_trigger:$('bt_trail_trig').value,
    trail_stop:   $('bt_trail_stop').value,
    time_start:   $('bt_time_start').value,
    time_end:     $('bt_time_end').value,
    max_daily:    $('bt_max_daily').value,
    high_chase_thr:  $('bt_high_chase').value,
    max_daily_chg:   $('bt_max_daily_chg').value,
    min_price:       $('bt_min_price').value,
    max_loss_krw:    $('bt_max_loss_krw').value,
    min_margin:      $('bt_min_margin').value,
    use_defense_filters: $('bt_use_defense')?.checked ? 1 : 0,
  };
  const qs = new URLSearchParams(params).toString();
  showSpinner(true);
  fetch('/api/backtest/scalping?' + qs)
    .then(r => r.json())
    .then(d => { showSpinner(false); renderBacktest(d); })
    .catch(err => { showSpinner(false); alert('오류: ' + err); });
}

function renderBacktest(d) {
  const s = d.summary;
  const p = d.params;

  $('bt_params_bar').style.display = '';
  const universeLabel = (p.universe_source === 'history' && p.universe_history_slots > 0)
    ? `유니버스: 저장 이력 ${p.universe_history_slots}슬롯`
    : '유니버스: 시뮬레이션(폴백)';
  const tr = (v) => (typeof v === 'number' ? Number(v).toFixed(1) : v);
  const btSummaryLine =
    `<span class="badge ${p.universe_source === 'history' ? 'badge-info' : 'badge-secondary'}" title="target_candidates_history 사용 여부">${universeLabel}</span> ` +
    `<span class="badge ${p.use_defense_filters ? 'badge-info' : 'badge-secondary'}">방어:${p.use_defense_filters ? 'ON' : 'OFF'}</span> ` +
    `RSI(${p.rsi_period}) &lt;${p.rsi_oversold} / &gt;${p.rsi_overbought ?? 75} 과열차단 | ` +
    `손절-${tr(p.sl_pct)}% 익절+${tr(p.tp_pct)}% | ` +
    `낙폭≥${tr(p.drop_rate)}% | 쿨다운${p.cooldown_min}분 | ` +
    `거래량${tr(p.vol_mult)}배 | 트레일(${tr(p.trail_trigger)}%발동/${tr(p.trail_stop)}%추적) | ` +
    `${p.time_window} | 일${p.max_daily}회 | ` +
    `종목수 ${p.codes_analyzed}개`;
  $('bt_params_bar').innerHTML = btSummaryLine;
  const ctx = $('bt_trade_context');
  if (ctx) {
    ctx.innerHTML = '📋 ' + btSummaryLine;
    ctx.style.display = '';
  }

  $('b_total').textContent   = fmt(s.total_trades) + '건';
  $('b_winrate').textContent = s.win_rate + '%';
  colorPnl($('b_winrate'), s.win_rate - 50);

  $('b_pnl').textContent = fmt(s.total_pnl) + '원';
  colorPnl($('b_pnl'), s.total_pnl);

  $('b_pf').textContent = s.profit_factor >= 999 ? '∞' : s.profit_factor;
  colorPnl($('b_pf'), s.profit_factor - 1);

  $('b_mdd').textContent  = '-' + fmt(s.max_drawdown) + '원';
  $('b_hold').textContent = s.avg_hold_min + '분';

  if (s.total_trades > 0) {
    $('bt_winbar_card').style.display = '';
    const wr = s.win_rate;
    $('b_win_label').textContent  = `🟢 익절+손절 승 ${s.win_trades}건 (${wr}%)`;
    $('b_loss_label').textContent = `🔴 패 ${s.loss_trades}건 (${(100-wr).toFixed(1)}%)`;
    $('b_ratio_g').style.width = wr + '%';
    $('b_ratio_r').style.width = (100 - wr) + '%';
  }

  lineChart('b_equity_chart',
    d.equity.map(e => e.date.slice(0,4)+'-'+e.date.slice(4,6)+'-'+e.date.slice(6)),
    d.equity.map(e => e.cum_pnl), '가상누적손익');

  const rKeys = Object.keys(d.reasons);
  doughnutChart('b_reason_chart', rKeys, rKeys.map(k => d.reasons[k]));

  barChart('b_daily_chart',
    d.daily.map(e => e.date.slice(5)),
    d.daily.map(e => e.pnl));

  const tbody = $('bt_tbody');
  tbody.innerHTML = '';
  const rows = [...d.trades].reverse();
  rows.forEach(t => {
    const pnl   = t.pnl;
    const badge = pnl > 0 ? 'badge-win' : (pnl < 0 ? 'badge-loss' : 'badge-flat');
    const pnlCls = pnl > 0 ? 'text-pnl-pos' : (pnl < 0 ? 'text-pnl-neg' : '');
    const bt = t.buy_time  || '';
    const st = t.sell_time || '';
    const nameCell = (t.name || t.code) ? `${t.name || t.code}<br><span style="color:var(--muted);font-size:11px">${t.code}</span>` : t.code;
    tbody.insertAdjacentHTML('beforeend', `
      <tr>
        <td>${nameCell}</td>
        <td style="font-size:11px">${bt.slice(0,4)+'-'+bt.slice(4,6)+'-'+bt.slice(6,8)+' '+bt.slice(8,10)+':'+bt.slice(10,12)}</td>
        <td style="font-size:11px">${st.slice(0,4)+'-'+st.slice(4,6)+'-'+st.slice(6,8)+' '+st.slice(8,10)+':'+st.slice(10,12)}</td>
        <td>${fmt(t.buy_price)}</td>
        <td>${fmt(t.sell_price)}</td>
        <td>${t.qty}</td>
        <td class="${pnlCls}">${fmt(pnl)}</td>
        <td><span class="${badge}">${fmtPct(t.profit_rate)}</span></td>
        <td>${t.hold_min}</td>
        <td style="font-size:11px">${t.sell_reason}</td>
        <td>${t.rsi_entry}</td>
      </tr>`);
  });
}

// ────────────────────────────────────────────
// 꼬리잡기 백테스트
// ────────────────────────────────────────────
function runTailBacktest() {
  const params = {
    start:              $('tl_start').value,
    end:                $('tl_end').value,
    min_drop_rate:      $('tl_drop').value,
    min_recovery_ratio: $('tl_rec').value,
    tail_ratio_min:     $('tl_tail').value,
    tail_pct_min:       $('tl_tail_pct').value,
    max_rec_3m:         $('tl_max_rec_3m').value,
    sl_pct:             $('tl_sl').value,
    tp_pct:             $('tl_tp').value,
    shoulder_min_high:  $('tl_smin').value,
    shoulder_cut_pct:   $('tl_scut').value,
    high_chase_thr:     $('tl_high_chase').value,
    rsi_period:         $('tl_rsi_period').value,
    rsi_threshold:      $('tl_rsi').value,
    cooldown_min:       $('tl_cool').value,
    time_start:         $('tl_ts').value,
    time_end:           $('tl_te').value,
    max_daily:          $('tl_maxd').value,
    slot_money:         $('tl_slot').value,
    min_price:          $('tl_min_price').value,
    max_daily_change:   $('tl_max_daily_change').value,
    ma20_max_above:     $('tl_ma20_above').value,
    max_loss_krw:       $('tl_max_loss_krw').value,
    min_drop_pct_for_loss_cut: (parseFloat($('tl_min_drop_loss_cut').value) / 100) || 0.015,
    stop_atr_mult:      $('tl_stop_atr').value,
    target_atr_mult:    $('tl_target_atr').value,
  };
  const qs = new URLSearchParams(params).toString();
  showSpinner(true);
  fetch('/api/backtest/tail?' + qs)
    .then(r => r.json())
    .then(d => { showSpinner(false); renderTailBacktest(d); })
    .catch(err => { showSpinner(false); alert('오류: ' + err); });
}

function renderTailBacktest(d) {
  const s = d.summary;
  const p = d.params;

  $('tl_params_bar').style.display = '';
  const tlUniverseLabel = (p.universe_source === 'history' && (p.universe_history_slots || 0) > 0)
    ? `유니버스: 저장 이력 ${p.universe_history_slots}슬롯`
    : '유니버스: 전체종목(폴백)';
  const tlSummaryLine =
    `<span class="badge ${p.universe_source === 'history' ? 'badge-info' : 'badge-secondary'}" title="target_candidates_history 사용 여부">${tlUniverseLabel}</span> ` +
    `낙폭≥${p.min_drop_rate}% | 회복률≥${p.min_recovery_ratio}% | 꼬리/몸통≥${p.tail_ratio_min} | ` +
    `손절-${p.sl_pct}% 익절+${p.tp_pct}% | 어깨컷(${p.shoulder_min_high}%발동/${p.shoulder_cut_pct}%하락) | ` +
    (p.max_loss_krw != null ? `최대손실컷 ${Number(p.max_loss_krw).toLocaleString()}원 | ` : '') +
    `RSI(${p.rsi_period})과열<${p.rsi_threshold} | 쿨다운${p.cooldown_min}분 | ${p.time_window || '930-1500'} | 일${p.max_daily || 3}회 | 종목수 ${p.codes_analyzed}개`;
  $('tl_params_bar').innerHTML = tlSummaryLine;
  const tlCtx = $('tl_trade_context');
  if (tlCtx) {
    tlCtx.innerHTML = '📋 ' + tlSummaryLine;
    tlCtx.style.display = '';
  }

  $('tl_total').textContent   = (s.total_trades||0) + '건';
  $('tl_winrate').textContent = (s.win_rate||0) + '%';
  colorPnl($('tl_winrate'), (s.win_rate||0) - 50);

  $('tl_pnl').textContent = fmt(s.total_pnl) + '원';
  colorPnl($('tl_pnl'), s.total_pnl);

  $('tl_pf').textContent  = (s.profit_factor||0) >= 999 ? '∞' : (s.profit_factor||0);
  colorPnl($('tl_pf'), (s.profit_factor||0) - 1);

  $('tl_mdd').textContent  = '-' + fmt(s.max_drawdown) + '원';
  $('tl_hold').textContent = (s.avg_hold_min||0) + '분';

  if ((s.total_trades||0) > 0) {
    $('tl_winbar_card').style.display = '';
    const wr = s.win_rate||0;
    $('tl_win_label').textContent  = `🟢 승 ${s.win_trades}건 (${wr}%)`;
    $('tl_loss_label').textContent = `🔴 패 ${s.loss_trades}건 (${(100-wr).toFixed(1)}%)`;
    $('tl_ratio_g').style.width = wr + '%';
    $('tl_ratio_r').style.width = (100 - wr) + '%';
  }

  // 누적 손익 곡선
  lineChart('tl_equity_chart',
    d.equity.map(e => e.date),
    d.equity.map(e => e.cum_pnl),
    '가상누적손익', '#3fb950');

  // 매도 이유 도넛
  const rKeys = Object.keys(d.reasons || {});
  doughnutChart('tl_reason_chart', rKeys, rKeys.map(k => d.reasons[k]));

  // 일별 바
  barChart('tl_daily_chart',
    d.daily.map(e => e.date.slice(5)),
    d.daily.map(e => e.pnl));

  // 거래 목록
  const tbody = $('tl_tbody');
  tbody.innerHTML = '';
  const rows = [...(d.trades||[])].reverse();
  rows.forEach(t => {
    const pnl    = t.pnl;
    const badge  = pnl > 0 ? 'badge-win' : (pnl < 0 ? 'badge-loss' : 'badge-flat');
    const pnlCls = pnl > 0 ? 'text-pnl-pos' : (pnl < 0 ? 'text-pnl-neg' : '');
    const entry  = t.entry_time || '';
    const exit   = t.exit_time  || '';
    const ep     = t.entry || 0;
    const xp     = t.exit  || 0;
    const qty    = ep > 0 ? Math.max(1, Math.floor(1000000 / ep)) : 0;
    const ratePct= ep > 0 ? ((xp - ep) / ep * 100).toFixed(2) : '0.00';
    function fmtCT(ct) {
      if (!ct || ct.length < 12) return ct || '';
      return ct.slice(0,4)+'-'+ct.slice(4,6)+'-'+ct.slice(6,8)+' '+ct.slice(8,10)+':'+ct.slice(10,12);
    }
    const nameCell = (t.name || t.code) ? `${t.name || t.code}<br><span style="color:var(--muted);font-size:11px">${t.code}</span>` : t.code;
    tbody.insertAdjacentHTML('beforeend', `
      <tr>
        <td>${nameCell}</td>
        <td style="font-size:11px">${fmtCT(entry)}</td>
        <td style="font-size:11px">${fmtCT(exit)}</td>
        <td>${fmt(ep)}</td>
        <td>${fmt(xp)}</td>
        <td>${qty}</td>
        <td class="${pnlCls}">${fmt(pnl)}</td>
        <td>${t.hold_min}</td>
        <td><span class="${badge}">${t.reason}</span></td>
      </tr>`);
  });
}

// ────────────────────────────────────────────
// 홀딩 전략
// ────────────────────────────────────────────
let hdCurrentCode = null;
let hdCurrentName = null;
let hdCurrentCfg  = {};

function hdLoadStocks() {
  fetch('/api/holding/stocks')
    .then(r => r.json())
    .then(stocks => hdRenderStockList(stocks))
    .catch(err => alert('종목 로드 실패: ' + err));
}

function hdRenderStockList(stocks) {
  const list = $('hd_stock_list');
  list.innerHTML = '';
  stocks.forEach(s => {
    const candleInfo = s.candle_count > 0
      ? `<span style="color:var(--green);font-size:11px">✅ 일봉 ${s.candle_count}봉 (${s.candle_min||'?'} ~ ${s.candle_max||'?'})</span>`
      : `<span style="color:var(--red);font-size:11px">⚠️ 일봉 없음</span>`;
    const min60Info = s.min60_count > 0
      ? `<span style="color:#58a6ff;font-size:11px">⏱ 60분봉 ${s.min60_count}봉 (${s.min60_min||'?'} ~ ${s.min60_max||'?'})</span>`
      : `<span style="color:var(--muted);font-size:11px">⏱ 60분봉 없음</span>`;
    list.insertAdjacentHTML('beforeend', `
      <div class="col-12 col-md-6 col-lg-4">
        <div class="card p-3">
          <div class="d-flex justify-content-between align-items-start mb-1">
            <div>
              <b style="font-size:15px">${s.name||s.code}</b>
              <span style="color:var(--muted);font-size:12px;margin-left:6px">${s.code}</span>
            </div>
            <div class="text-end">${candleInfo}<br>${min60Info}</div>
          </div>
          <!-- 파라미터 인풋 -->
          <div class="row g-1" style="font-size:12px">
            <div class="col-12" style="color:var(--accent);font-size:11px;font-weight:600;padding:2px 4px">▶ 추세 설정</div>
            <div class="col-4"><label>MA단기 <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_ma_fast" value="${s.ma_fast||20}" min="5" max="60" step="1"></label></div>
            <div class="col-4"><label>MA장기 <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_ma_slow" value="${s.ma_slow||60}" min="20" max="200" step="5"></label></div>
            <div class="col-4"><label>트레일(%) <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_trail_stop_pct" value="${s.trail_stop_pct??8}" min="0" max="30" step="0.5" title="고점 대비 X% 하락 시 추세이탈 청산. 0=비활성"></label></div>
            <div class="col-6"><label>추세필터
              <select class="form-control form-control-sm" id="cfg_${s.code}_trend_filter">
                <option value="1" ${(s.trend_filter??1)>=0.5?'selected':''}>ON (추세추종)</option>
                <option value="0" ${(s.trend_filter??1)<0.5?'selected':''}>OFF (역추세)</option>
              </select></label></div>
            <div class="col-12" style="color:var(--muted);font-size:11px;font-weight:600;padding:2px 4px">▶ RSI 매수/매도</div>
            <div class="col-6"><label>RSI기간 <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_rsi_period" value="${s.rsi_period||14}" min="5" max="30"></label></div>
            <div class="col-6"><label>매수1 RSI≤ <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_rsi_buy1" value="${s.rsi_buy1||55}" min="10" max="70" step="1" title="추세 ON: 1차 눌림목 / 추세 OFF: 1단계"></label></div>
            <div class="col-6"><label>매수2 RSI≤ <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_rsi_buy2" value="${s.rsi_buy2||45}" min="10" max="65" step="1" title="추세 ON: 2차 눌림목 / 추세 OFF: 2단계"></label></div>
            <div class="col-6"><label>매수3 RSI≤ <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_rsi_buy3" value="${s.rsi_buy3||35}" min="5" max="55" step="1" title="추세 OFF 전용 3단계 (추세 ON에선 미사용)"></label></div>
            <div class="col-6"><label>매도 RSI≥ <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_rsi_sell" value="${s.rsi_sell||75}" min="50" max="95" step="1"></label></div>
            <div class="col-6"><label>익절(%) <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_take_profit_pct" value="${s.take_profit_pct||15}" min="1" max="50" step="1" title="추세장엔 크게 잡아야 탈 안 남"></label></div>
            <div class="col-6"><label>손절(%) <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_stop_loss_pct" value="${s.stop_loss_pct||10}" min="2" max="30" step="0.5"></label></div>
            <div class="col-6"><label>투자금(원) <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_slot_money" value="${s.slot_money||3000000}" min="500000" step="500000"></label></div>
            <div class="col-12" style="color:var(--muted);font-size:11px;font-weight:600;padding:2px 4px">▶ 샹들리에 엑시트 (추세장 청산 — 고점 − ATR × 배수)</div>
            <div class="col-6"><label title="ATR 계산 기간 (기본 14일)">ATR기간
              <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_atr_period"
                value="${s.atr_period||14}" min="5" max="30" step="1"></label></div>
            <div class="col-6"><label title="샹들리에 배수. 2=타이트 / 3=기본 / 4=여유 (클수록 오래 홀딩)">샹들리에배수
              <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_atr_mult"
                value="${s.atr_mult||3}" min="1" max="6" step="0.5"></label></div>
            <div class="col-12" style="color:var(--muted);font-size:11px;font-weight:600;padding:2px 4px">▶ 낙폭 필터 (0=비활성 / 예: 20 → 고점 대비 20% 이상 빠진 날만 매수)</div>
            <div class="col-4"><label title="역대 최고가(ATH) 대비 현재가 낙폭이 X% 이상일 때만 매수. 0=끔">ATH낙폭(%)
              <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_ath_drop_min_pct"
                value="${s.ath_drop_min_pct||0}" min="0" max="70" step="5"></label></div>
            <div class="col-4"><label title="당해연도 고점 대비 낙폭 필터. 0=끔">연도낙폭(%)
              <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_year_drop_min_pct"
                value="${s.year_drop_min_pct||0}" min="0" max="70" step="5"></label></div>
            <div class="col-4"><label title="52주 고점 대비 낙폭 필터. 0=끔">52주낙폭(%)
              <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_w52_drop_min_pct"
                value="${s.w52_drop_min_pct||0}" min="0" max="70" step="5"></label></div>
          </div>
          <!-- 버튼 (일봉 + 60분봉 수집) -->
          <div class="d-flex gap-1 mt-2 flex-wrap">
            <button class="btn btn-sm btn-outline-secondary" onclick="hdFetchCandles('${s.code}','${s.name||s.code}')" title="KIS 일봉 수집">📥 일봉수집</button>
            <button class="btn btn-sm btn-outline-secondary" style="color:#58a6ff;border-color:#58a6ff" onclick="hdFetchMinCandlesKiwoom('${s.code}','${s.name||s.code}')" title="키움 60분봉 — 1회 900봉(약 128영업일), 연속조회로 2년치 수집. KIWOOM_APP_KEY 설정 필요">🗂 키움60분봉</button>
            <button class="btn btn-sm btn-primary" onclick="hdRunBacktest('${s.code}','${s.name||s.code}')" title="추세추종 전략 (MA + RSI)">🚀 추세BT</button>
            <button class="btn btn-sm btn-info" style="color:#fff" onclick="hdRunV1Backtest('${s.code}','${s.name||s.code}')" title="RSI 3단계 분할매수 전략 (횡보장)">📊 분할매수BT</button>
            <button class="btn btn-sm btn-warning" onclick="hdParamSearch('${s.code}','${s.name||s.code}')" title="추세추종 파라미터 탐색">🔍 추세탐색</button>
            <button class="btn btn-sm btn-outline-info" onclick="hdV1ParamSearch('${s.code}','${s.name||s.code}')" title="분할매수 파라미터 탐색">🔍 분할탐색</button>
            <button class="btn btn-sm btn-success" onclick="hdSaveConfig('${s.code}','${s.name||s.code}')">💾 저장</button>
          </div>
        </div>
      </div>`);
  });
}

function hdGetCfg(code) {
  const fields = ['rsi_period','rsi_buy1','rsi_buy2','rsi_buy3','rsi_sell',
                  'take_profit_pct','stop_loss_pct','slot_money',
                  'atr_period','atr_mult',
                  'ma_fast','ma_slow','trail_stop_pct','trend_filter',
                  'ath_drop_min_pct','year_drop_min_pct','w52_drop_min_pct'];
  const cfg = {code};
  fields.forEach(f => {
    const el = $(`cfg_${code}_${f}`);
    if (el) cfg[f] = parseFloat(el.value);
  });
  return cfg;
}

function hdFetchCandles(code, name) {
  const start = $('hd_fetch_start').value;
  const end   = $('hd_end').value;
  if (!start || !end) { alert('캔들 수집 날짜를 설정하세요'); return; }
  showSpinner(true);
  fetch('/api/holding/candles/fetch', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({code, start, end}),
  })
    .then(r => r.json())
    .then(d => {
      showSpinner(false);
      if (d.error) { alert('❌ ' + d.error); return; }
      alert(`✅ ${name}(${code}) 일봉 수집 완료\n수집: ${d.fetched}봉 / 저장: ${d.saved}봉`);
      hdLoadStocks();
    })
    .catch(err => { showSpinner(false); alert('오류: ' + err); });
}

function hdFetchMinCandlesKiwoom(code, name) {
  // 키움 REST API (ka10080) 로 60분봉 수집 — 1회 900봉, 2년치 수집 가능
  const start = $('hd_fetch_start').value;
  const end   = $('hd_end').value;
  if (!start || !end) { alert('캔들 수집 날짜를 설정하세요'); return; }
  if (!confirm(`🗂 ${name}(${code}) 키움 60분봉 수집 시작\n기간: ${start} ~ ${end}\n\n키움 API는 1회 900봉(약 128영업일), 연속조회로 2년치 수집 가능.\nDB에 KIWOOM_APP_KEY / KIWOOM_APP_SECRET 설정 필요.\n계속하시겠습니까?`)) return;

  fetch('/api/holding/min_candles/fetch_kiwoom', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({code, start, end}),
  })
    .then(r => r.json())
    .then(d => {
      if (d.error) { alert('❌ ' + d.error); return; }
      const jobId = d.job_id;
      const statusEl = document.getElementById(`min_status_${code}`) || (() => {
        const el = document.createElement('span');
        el.id = `min_status_${code}`;
        el.style.cssText = 'font-size:11px;color:var(--accent);margin-left:6px';
        return el;
      })();
      function poll() {
        fetch(`/api/holding/min_candles/status/${jobId}`)
          .then(r => r.json())
          .then(j => {
            if (j.status === 'running') {
              statusEl.textContent = `⏳ 키움수집중… ${j.fetched}봉 → 저장 ${j.saved}봉`;
              setTimeout(poll, 2000);
            } else if (j.status === 'done') {
              statusEl.textContent = `✅ 완료! ${j.saved}봉 저장됨 (키움)`;
              hdLoadStocks();
            } else {
              statusEl.textContent = `❌ ${j.error}`;
            }
          })
          .catch(() => setTimeout(poll, 3000));
      }
      poll();
    })
    .catch(err => alert('오류: ' + err));
}

function hdFetchMinCandles(code, name) {
  // 60분봉 수집(KIS) — 백그라운드 스레드로 실행, 폴링으로 진행상황 표시
  const start = $('hd_fetch_start').value;
  const end   = $('hd_end').value;
  if (!start || !end) { alert('캔들 수집 날짜를 설정하세요'); return; }
  if (!confirm(`⏱ ${name}(${code}) KIS 60분봉 수집 시작\n기간: ${start} ~ ${end}\n\n⚠️ KIS는 최근 2일치 한계. 긴 기간은 [키움 60분봉]을 사용하세요.\n계속하시겠습니까?`)) return;

  fetch('/api/holding/min_candles/fetch', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({code, start, end, tf: 60}),
  })
    .then(r => r.json())
    .then(d => {
      if (d.error) { alert('❌ ' + d.error); return; }
      const jobId = d.job_id;
      // 수집 상태 표시 영역 업데이트 (스피너 대신 인라인 표시)
      const btn = document.querySelector(`[onclick*="hdFetchMinCandles('${code}'"]`);
      const statusEl = document.getElementById(`min_status_${code}`) || (() => {
        const el = document.createElement('span');
        el.id = `min_status_${code}`;
        el.style.cssText = 'font-size:11px;color:var(--accent);margin-left:6px';
        if (btn) btn.parentNode.insertBefore(el, btn.nextSibling);
        return el;
      })();

      function poll() {
        fetch(`/api/holding/min_candles/status/${jobId}`)
          .then(r => r.json())
          .then(j => {
            if (j.error) { statusEl.textContent = '❌ ' + j.error; return; }
            const d_str = j.current_date ? ` (${j.current_date.slice(0,4)}-${j.current_date.slice(4,6)}-${j.current_date.slice(6,8)})` : '';
            if (j.status === 'running') {
              statusEl.textContent = `⏳ 수집중… 1분봉 ${j.fetched}개 → 저장 ${j.saved}봉${d_str}`;
              setTimeout(poll, 2000);
            } else if (j.status === 'done') {
              statusEl.textContent = `✅ 완료! 저장 ${j.saved}봉`;
              hdLoadStocks();
            } else {
              statusEl.textContent = `❌ 오류: ${j.error}`;
            }
          })
          .catch(() => setTimeout(poll, 3000));
      }
      poll();
    })
    .catch(err => alert('오류: ' + err));
}

function hdRunMinBacktest(code, name) {
  // 60분봉 기반 백테스트 — 현재 카드 파라미터 적용
  const cfg   = hdGetCfg(code);
  const start = $('hd_start').value;
  const end   = $('hd_end').value;
  const qs = new URLSearchParams({code, start, end, tf: 60, ...cfg}).toString();
  showSpinner(true);
  fetch('/api/holding/min_backtest?' + qs)
    .then(r => r.json())
    .then(d => {
      showSpinner(false);
      if (d.error) { alert('❌ ' + d.error); return; }
      hdCurrentCode = code;
      hdCurrentName = name;
      hdCurrentCfg  = cfg;
      // 결과 렌더링 — 기존 일봉 백테스트와 동일 함수 재사용, 제목만 구분
      hdRenderResult(d, `[${name}] 60분봉 백테스트 결과 (${d.candle_count}봉)`);
    })
    .catch(err => { showSpinner(false); alert('오류: ' + err); });
}

function hdRunBacktest(code, name) {
  const cfg   = hdGetCfg(code);
  const start = $('hd_start').value;
  const end   = $('hd_end').value;
  const qs = new URLSearchParams({code, start, end, ...cfg}).toString();
  showSpinner(true);
  fetch('/api/holding/backtest?' + qs)
    .then(r => r.json())
    .then(d => {
      showSpinner(false);
      if (d.error) { alert('❌ ' + d.error); return; }
      hdCurrentCode = code;
      hdCurrentName = name;
      hdRenderResult(d, name);
    })
    .catch(err => { showSpinner(false); alert('오류: ' + err); });
}

function hdGetV1Cfg(code) {
  // V1 전략 파라미터: 카드에서 RSI/낙폭 관련 값만 수집 (MA/추세 파라미터 제외)
  const fields = ['rsi_period','rsi_buy1','rsi_buy2','rsi_buy3','rsi_sell',
                  'take_profit_pct','stop_loss_pct','slot_money',
                  'ath_drop_min_pct','year_drop_min_pct','w52_drop_min_pct'];
  const cfg = {code};
  fields.forEach(f => {
    const el = $(`cfg_${code}_${f}`);
    if (el) cfg[f] = parseFloat(el.value);
  });
  return cfg;
}

function hdRunV1Backtest(code, name) {
  // V1 RSI 분할매수 백테스트
  const cfg   = hdGetV1Cfg(code);
  const start = $('hd_start').value;
  const end   = $('hd_end').value;
  const qs = new URLSearchParams({code, start, end, ...cfg}).toString();
  showSpinner(true);
  fetch('/api/holding/v1/backtest?' + qs)
    .then(r => r.json())
    .then(d => {
      showSpinner(false);
      if (d.error) { alert('❌ ' + d.error); return; }
      hdRenderResult(d, name + ' [분할매수V1]');
    })
    .catch(err => { showSpinner(false); alert('오류: ' + err); });
}

function hdV1ParamSearch(code, name) {
  // V1 RSI 분할매수 파라미터탐색
  const cfg = hdGetV1Cfg(code);
  const start = $('hd_start').value;
  const end   = $('hd_end').value;
  const qs  = new URLSearchParams({code, start, end, ...cfg}).toString();
  showSpinner(true);
  fetch('/api/holding/v1/param_search?' + qs)
    .then(r => r.json())
    .then(d => {
      showSpinner(false);
      if (d.error) { alert('❌ ' + d.error); return; }
      hdRenderParamSearch(d.top, code, name + ' [분할매수V1]');
    })
    .catch(err => { showSpinner(false); alert('오류: ' + err); });
}

function hdParamSearch(code, name) {
  const start = $('hd_start').value;
  const end   = $('hd_end').value;
  // 카드의 현재 파라미터를 기준값으로 전달 (rsi_sell, rsi_period 등 그리드에 없는 값도 정확히 반영)
  const cfg = hdGetCfg(code);
  const qs  = new URLSearchParams({code, start, end, ...cfg}).toString();
  showSpinner(true);
  fetch('/api/holding/param_search?' + qs)
    .then(r => r.json())
    .then(d => {
      showSpinner(false);
      if (d.error) { alert('❌ ' + d.error); return; }
      hdRenderParamSearch(d.top, code, name);
    })
    .catch(err => { showSpinner(false); alert('오류: ' + err); });
}

function hdSaveConfig(code, name) {
  const cfg = hdGetCfg(code);
  cfg.name  = name;
  fetch(`/api/holding/config/${code}`, {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify(cfg),
  })
    .then(r => r.json())
    .then(d => { if (d.ok) alert(`✅ ${name}(${code}) 파라미터 저장 완료`); })
    .catch(err => alert('오류: ' + err));
}

function hdApplySearchResult(code, name, p) {
  // 탐색 결과를 카드 인풋에 반영
  Object.entries(p).forEach(([k, v]) => {
    const el = $(`cfg_${code}_${k}`);
    if (el) el.value = v;
  });
  alert(`✅ ${name} 탐색 결과를 카드에 반영했습니다.\n💾 설정저장 버튼을 눌러 DB에 저장하세요.`);
}

function hdRenderResult(d, name) {
  const s = d.summary;
  $('hd_result_area').style.display   = '';
  $('hd_search_result').style.display = 'none';
  $('hd_result_title').textContent    = `📈 [${name}] 백테스트 결과 (${d.candle_count}봉)`;

  $('hd_total').textContent = (s.total_trades||0) + '건';
  $('hd_wr').textContent    = (s.win_rate||0) + '%';
  colorPnl($('hd_wr'), (s.win_rate||0) - 50);
  $('hd_pnl').textContent   = fmt(s.total_pnl) + '원';
  colorPnl($('hd_pnl'), s.total_pnl);
  $('hd_pf').textContent    = (s.profit_factor||0) >= 999 ? '∞' : (s.profit_factor||0);
  colorPnl($('hd_pf'), (s.profit_factor||0) - 1);
  $('hd_mdd').textContent   = '-' + fmt(s.max_drawdown) + '원';
  $('hd_hold').textContent  = (s.avg_hold_days||0) + '일';

  // Buy & Hold 비교
  if (s.bnh_pct !== undefined) {
    $('hd_bnh_row').style.display = '';
    const sign = v => v >= 0 ? '+' : '';
    $('hd_bot_pct').textContent  = sign(s.bot_pct) + s.bot_pct + '%';
    colorPnl($('hd_bot_pct'), s.bot_pct);
    $('hd_bnh_pct').textContent  = sign(s.bnh_pct) + s.bnh_pct + '%';
    colorPnl($('hd_bnh_pct'), s.bnh_pct);
    $('hd_bnh_pnl').textContent  = sign(s.bnh_pnl) + fmt(s.bnh_pnl) + '원';
    colorPnl($('hd_bnh_pnl'), s.bnh_pnl);
    $('hd_alpha').textContent = sign(s.alpha_pct) + s.alpha_pct + '%p';
    colorPnl($('hd_alpha'), s.alpha_pct);
  }

  lineChart('hd_equity_chart', d.equity.map(e=>e.date), d.equity.map(e=>e.cum_pnl),
            '누적손익', '#bc8cff');
  const rKeys = Object.keys(d.reasons||{});
  doughnutChart('hd_reason_chart', rKeys, rKeys.map(k=>d.reasons[k]));

  const tbody = $('hd_trade_tbody');
  tbody.innerHTML = '';
  [...(d.trades||[])].reverse().forEach(t => {
    const pnlCls = t.pnl > 0 ? 'text-pnl-pos' : (t.pnl < 0 ? 'text-pnl-neg' : '');
    tbody.insertAdjacentHTML('beforeend', `
      <tr>
        <td>${t.buy_date}</td><td>${t.sell_date}</td>
        <td>${fmt(t.avg_price)}</td><td>${fmt(t.exit_price)}</td>
        <td>${t.qty}</td>
        <td class="${pnlCls}">${fmt(t.pnl)}</td>
        <td>${t.hold_days}</td>
        <td style="font-size:11px">${t.reason}</td>
      </tr>`);
  });
  $('hd_result_area').scrollIntoView({behavior:'smooth'});
}

function hdRenderParamSearch(top, code, name) {
  $('hd_result_area').style.display   = '';
  $('hd_search_result').style.display = '';
  $('hd_result_title').textContent    = `🔍 [${name}] 파라미터 탐색 결과`;

  // ── 파라미터 키 한글 레이블 매핑 ──────────────────────────────
  const labelMap = {
    ma_fast: 'MA단기', ma_slow: 'MA장기',
    atr_mult: '샹들리에배수', atr_period: 'ATR기간',
    trail_stop_pct: '트레일%', trend_filter: '추세필터',
    rsi_buy1: '매수1', rsi_buy2: '매수2', rsi_buy3: '매수3',
    rsi_sell: '매도RSI', take_profit_pct: '익절%', stop_loss_pct: '손절%',
    ath_drop_min_pct: 'ATH낙폭%', year_drop_min_pct: '연도낙폭%', w52_drop_min_pct: '52주낙폭%',
  };

  // ── 실제 params 키로 헤더 동적 생성 ───────────────────────────
  const keys = top && top.length ? Object.keys(top[0].params) : [];
  const thead = $('hd_search_thead');
  thead.innerHTML = '<tr>' +
    keys.map(k => `<th>${labelMap[k] || k}</th>`).join('') +
    '<th>손익(원)</th><th>승률</th><th>거래</th><th>PF</th><th>보유(일)</th><th>적용</th></tr>';

  const tbody = $('hd_search_tbody');
  tbody.innerHTML = '';
  (top||[]).forEach((r, idx) => {
    const p = r.params;
    const pnlCls = r.total_pnl > 0 ? 'text-pnl-pos' : 'text-pnl-neg';
    const paramCells = keys.map(k => {
      const v = p[k];
      if (v === undefined || v === null) return '<td>-</td>';
      // % 단위 컬럼은 % 표시
      const isPct = k.includes('pct') || k.includes('rate');
      return `<td>${isPct ? v+'%' : v}</td>`;
    }).join('');
    tbody.insertAdjacentHTML('beforeend', `
      <tr>
        ${paramCells}
        <td class="${pnlCls}">${fmt(r.total_pnl)}</td>
        <td>${r.win_rate}%</td><td>${r.total_trades}</td>
        <td>${r.pf}</td><td>${r.avg_hold}일</td>
        <td><button class="btn btn-xs btn-outline-success" style="font-size:11px;padding:1px 6px"
            onclick='hdApplySearchResult("${code}","${name}",${JSON.stringify(p)})'>적용</button></td>
      </tr>`);
  });
  $('hd_result_area').scrollIntoView({behavior:'smooth'});
}

// ────────────────────────────────────────────
// 스피너
// ────────────────────────────────────────────
function showSpinner(v) {
  $('spinner').classList.toggle('show', v);
}

// ────────────────────────────────────────────
// 최초 로드
// ────────────────────────────────────────────
loadActual();
</script>
</body>
</html>"""


@app.route("/")
def index():
    return render_template_string(HTML_TEMPLATE)


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5050, debug=False)
