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

탭1. 실거래 분석   → trade_history 기반 (SCALP / SHORT / MOMENTUM / UPDOW / BREAKOUT / HOLDING 등)
탭2. 오늘 운영     → 당일 전략별·합계 거래대금·뽀찌(운용한도) 사용량·총수익률 (봇 재시작 불필요)
탭2b. 운영 설정   → 일일익절·전략ON/OFF·운용한도·TRIGGER 필터 등 env 실시간 조회·저장
탭3. 보유·매도     → active_trades + 실계좌 대조, OrderManager 시장가 매도 (HTS 수동매도 DB 꼬임 방지)
탭3. 스캘핑 백테스트 → ws_candles 1분봉 가격 재현(Price-Replay) 백테스트
탭4. 꼬리잡기 백테스트 → ws_candles 3분봉 기반, tail_engine 연동 (V3 방어 파라미터 지원)
"""

import sys, os, math, json, logging, threading, uuid
from datetime import datetime, timedelta
from typing import Any, 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 kis_trader.strategies import updow_holding_cfg as uhc  # _updow_db(분봉 BT 테이블 보장)·영구구독 메타 resolve
from kis_trader.utils.strategy_ids import (
    EXCLUDED_STRATEGY_IDS,
    KIS_TRADER_STRATEGY_IDS,
    PORTFOLIO_EXCLUDED_STRATEGY_IDS,
    canonical_strategy_id,
    is_bot_strategy,
    strategy_like_pattern,
    strategy_prefix_for_filter,
)

_LEGACY_STRATEGY_SQL = ", ".join(["%s"] * len(EXCLUDED_STRATEGY_IDS))
_PORTFOLIO_EXCLUDED_SQL = ", ".join(["%s"] * len(PORTFOLIO_EXCLUDED_STRATEGY_IDS))

from flask import Flask, jsonify, request, render_template

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

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

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

# 보유·매도 탭: OrderManager / 시세 client (프로세스 내 1회 초기화)
_portfolio_order_mgr = None
_portfolio_market_client = None
_portfolio_infra_lock = threading.Lock()


def _portfolio_build_market_client(trade_client):
    """kis_trader.main 과 동일 정책: 시세는 실키, 매매는 KIS_MOCK."""
    from kis_trader.execution.kis_client import KISClient
    from kis_trader.utils.env import get_env_from_db

    if not trade_client.mock:
        return trade_client
    real_key = (get_env_from_db("KIS_APP_KEY_REAL", "") or "").strip()
    real_secret = (get_env_from_db("KIS_APP_SECRET_REAL", "") or "").strip()
    if not real_key or not real_secret:
        logger.warning(
            "KIS_APP_KEY_REAL 미설정 → 보유탭 시세도 모의 client 사용 (현재가 조회 제한 가능)"
        )
        return trade_client
    return KISClient(mock=False, app_key=real_key, app_secret=real_secret)


def _portfolio_infra():
    """웹 수동 매도용 OrderManager (봇 main 과 동일 매도·DB 경로)."""
    global _portfolio_order_mgr, _portfolio_market_client
    with _portfolio_infra_lock:
        if _portfolio_order_mgr is not None:
            return _portfolio_order_mgr, _portfolio_market_client
        from kis_trader.database.db_manager import get_db
        from kis_trader.execution.account_cash import AccountCashLedger
        from kis_trader.execution.kis_client import KISClient
        from kis_trader.execution.order_manager import OrderManager

        db = get_db()
        trade_client = KISClient()
        _portfolio_order_mgr = OrderManager(
            client=trade_client, db=db, cash_ledger=AccountCashLedger(db),
        )
        _portfolio_market_client = _portfolio_build_market_client(trade_client)
        return _portfolio_order_mgr, _portfolio_market_client


def _portfolio_origin_sets() -> tuple:
    """미등록 보유분 origin 판별 — orphan_reconcile 모듈과 동일 로직."""
    from kis_trader.execution.orphan_reconcile import get_portfolio_origin_sets
    return get_portfolio_origin_sets(_db())


def _list_active_trades_rows(
    strategy_filter: Optional[str] = None,
    *,
    for_portfolio: bool = False,
) -> List[Dict[str, Any]]:
    """active_trades 전 행 (code+strategy 복합키 — get_active_trades dict 와 달리 중복 없음)."""
    db = _db()
    sql = "SELECT * FROM active_trades"
    params: List[Any] = []
    clauses: List[str] = []
    like_pat = strategy_like_pattern(strategy_filter)
    if like_pat:
        clauses.append("strategy LIKE %s")
        params.append(like_pat)
    excluded = PORTFOLIO_EXCLUDED_STRATEGY_IDS if for_portfolio else EXCLUDED_STRATEGY_IDS
    ex_sql = _PORTFOLIO_EXCLUDED_SQL if for_portfolio else _LEGACY_STRATEGY_SQL
    clauses.append(f"strategy NOT IN ({ex_sql})")
    params.extend(list(excluded))
    if clauses:
        sql += " WHERE " + " AND ".join(clauses)
    sql += " ORDER BY strategy, code"
    try:
        cur = db.conn.execute(sql, tuple(params))
        return list(cur.fetchall() or [])
    except Exception as e:
        logger.error("active_trades 조회 실패: %s", e)
        return []


def _portfolio_price_fast(
    row: Dict[str, Any],
    broker_row: Optional[Dict[str, Any]] = None,
) -> float:
    """
    보유 목록용 현재가 — REST 시세 N회 호출 없음 (느림 방지).
    실잔고 평가금/수량 → DB current_price → 매수가 순.
    """
    br = broker_row or {}
    try:
        qty = int(br.get("qty") or 0)
        evlu = float(br.get("evlu_amt") or 0)
        if qty > 0 and evlu > 0:
            return evlu / qty
        prpr = abs(float(str(br.get("current_price") or br.get("prpr") or 0).replace(",", "")))
        if prpr > 0:
            return prpr
    except (TypeError, ValueError):
        pass
    try:
        cp = float(row.get("current_price") or 0)
        if cp > 0:
            return cp
    except (TypeError, ValueError):
        pass
    return float(row.get("avg_buy_price") or 0)


def _portfolio_live_price(code: str, row: Dict[str, Any], market_client) -> float:
    """매도 직전 참고가 — 1종목만 시세 API (필요 시)."""
    try:
        if market_client is not None:
            pd_ = market_client.inquire_price(code)
            if pd_:
                px = abs(float(str(pd_.get("stck_prpr", 0)).replace(",", "")))
                if px > 0:
                    return px
    except Exception as e:
        logger.debug("inquire_price %s: %s", code, e)
    return _portfolio_price_fast(row)

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

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


def _get_fee_defaults() -> dict:
    """
    DB 병합 스냅샷(env_config 공통)에서 수수료/세금 기본값 로드.
    """
    try:
        from kis_trader.utils.env import get_merged_env_dict
        r = get_merged_env_dict()
        if r:
            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 _strategy_env(strategy_id: str) -> Dict[str, Any]:
    """config_{strategy} + env_config 병합 — 웹·백테 단일 소스."""
    from kis_trader.utils.env import get_strategy_env_dict
    return get_strategy_env_dict(strategy_id)


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' 필드 추가.

    1) ``stock_meta`` (키움 테마 스크립트 등으로 채운 마스터)
    2) 없으면 ``target_candidates_history`` 최근 스냅샷 name (조건검색 적재)
    3) 둘 다 없으면 code 그대로
    """
    if not trades:
        return
    codes = list({str(t.get("code")).strip() for t in trades if t.get("code")})
    if not codes:
        return
    code_to_name: Dict[str, str] = {}

    try:
        placeholders = ", ".join(["%s"] * len(codes))
        rows = db.conn.execute(
            "SELECT code, name FROM stock_meta WHERE code IN (" + placeholders + ")",
            codes,
        ).fetchall()
        for r in rows:
            c = str(r["code"]).strip()
            n = (r.get("name") or "").strip()
            if n and n != c:
                code_to_name[c] = n
    except Exception as exc:
        logger.debug("stock_meta 이름 조회 실패: %s", exc)

    missing = [c for c in codes if c not in code_to_name]
    if missing:
        try:
            ph = ", ".join(["%s"] * len(missing))
            rows = db.conn.execute(
                "SELECT code, name FROM target_candidates_history "
                "WHERE code IN (" + ph + ") AND name IS NOT NULL AND name != '' "
                "ORDER BY COALESCE(event_time, scan_time) DESC, id DESC",
                missing,
            ).fetchall()
            for r in rows:
                c = str(r["code"]).strip()
                if c in code_to_name:
                    continue
                n = (r.get("name") or "").strip()
                if n and n != c:
                    code_to_name[c] = n
        except Exception as exc:
            logger.debug("target_candidates_history 이름 조회 실패: %s", exc)

    for t in trades:
        c = str(t.get("code") or "").strip()
        t["name"] = code_to_name.get(c, c)


def _norm_ts(t: Dict[str, Any], keys) -> str:
    """거래 dict 에서 키 후보 중 첫 유효값을 14자리 타임스탬프 문자열로 정규화."""
    for k in keys:
        v = t.get(k)
        if v is not None and str(v).strip():
            s = str(v).replace("-", "").replace(":", "").replace(" ", "").replace("T", "")
            return s[:14].ljust(14, "0")
    return ""


def _trade_exit_sort_key(t: Dict[str, Any]):
    """가상/실거래 행 — 매도(청산) 시각 정렬용 (엄격한 전순서).

    매도시각만으로는 같은 분(09:02:00)에 청산된 거래가 동점이 되어,
    오름차순(누적손익 계산)과 내림차순(화면 표시)의 동점 처리가 어긋나면서
    누적손익 컬럼이 표시순과 따로 노는 버그가 있었다. 매수시각·종목·가격을
    보조키로 추가해 동점을 제거 → 두 정렬이 정확히 거울상이 되도록 한다.
    """
    exit_ts = _norm_ts(t, ("exit_time", "sell_time", "sell_date", "buy_time", "entry_time", "buy_date"))
    entry_ts = _norm_ts(t, ("entry_time", "buy_time", "buy_date"))
    return (
        exit_ts,
        entry_ts,
        str(t.get("code") or ""),
        str(t.get("buy_price") or t.get("entry_price") or ""),
        str(t.get("qty") or t.get("quantity") or ""),
    )


def _int_display_price(v: Any) -> int:
    """거래내역 표시용 — 주식 가격 정수(원)."""
    try:
        return int(round(float(v or 0)))
    except (TypeError, ValueError):
        return 0


def _trade_with_int_prices(trade: Dict) -> Dict:
    """웹 거래표 매수가·매도가 — 소수점 제거."""
    out = dict(trade)
    for k in (
        "buy_price", "sell_price", "entry_price", "exit_price",
        "entry", "exit", "avg_price",
        "pnl", "realized_pnl", "unrealized_pnl", "cum_pnl",
    ):
        if k in out and out[k] is not None and out[k] != "":
            out[k] = _int_display_price(out[k])
    return out


def _trades_recent_first(trades: List[Dict], limit: int = 200) -> List[Dict]:
    """매도 시각 기준 최신순 상위 limit 건 (가상 거래 내역 표시용)."""
    if not trades:
        return []
    ordered = sorted(trades, key=_trade_exit_sort_key, reverse=True)
    cap = max(0, int(limit))
    sliced = ordered[:cap] if cap else ordered
    return [_trade_with_int_prices(t) for t in sliced]


def _momentum_source_label(kind: str, src: str) -> str:
    s = str(src or "").strip().lower()
    if kind == "entry":
        if s == "ws_ticks":
            return "틱진입"
        if s == "ohlc_open":
            return "시가"
        return s or "-"
    if s == "ws_ticks":
        return "틱청산"
    if s == "ohlc_bar":
        return "OHLC"
    return s or "-"


def _enrich_momentum_trades_debug(
    trades: List[Dict],
    *,
    total_budget_krw: float,
) -> None:
    """매도 시각 순 누적손익·틱/OHLC 디버그 라벨 (모멘텀 웹 거래표)."""
    if not trades:
        return
    ordered = sorted(trades, key=_trade_exit_sort_key)
    cum = 0.0
    tb = float(total_budget_krw or 0)
    for t in ordered:
        pnl = float(t.get("pnl") or 0)
        cum += pnl
        t["cum_pnl"] = int(round(cum))
        t["cum_return_pct"] = round(cum / tb * 100.0, 2) if tb > 0 else 0.0
        el = _momentum_source_label("entry", str(t.get("entry_source") or ""))
        xl = _momentum_source_label("exit", str(t.get("exit_source") or ""))
        t["entry_source_label"] = el
        t["exit_source_label"] = xl
        t["debug_tick"] = f"{el}→{xl}"


def _resolve_backtest_universe(
    db: TradeDB,
    start_key: str,
    end_key: str,
    use_saved_history: bool,
    codes_candles: Optional[Dict[str, List[Dict]]] = None,
    sim_kind: Optional[str] = None,
    scan_interval_min: int = 5,
    strategy_id: Optional[str] = None,
) -> Tuple[Optional[Dict[str, List[str]]], str, int, int]:
    """
    백테스트 유니버스 소스 통합 (param_search·신봇 실매와 동일 조회).

    - ``use_saved_history=True`` + ``strategy_id``:
      ``TradeDBExt.get_universe_by_candle_time`` — 전략별 event_time → 1분봉 키.
    - ``sim_kind`` reversal/momentum: 이력 없거나 미사용 시 ``scalping_engine`` 시뮬 (5분 슬롯).
    - 둘 다 아니면 ``None`` → ws_candles 전 종목 (꼬리·돌파).

    Returns:
        (universe_by_slot, source_label, history_bin_count, engine_scan_interval_min)
    """
    start_ymd = start_key[:8]
    end_ymd = end_key[:8]
    sim_interval = scan_interval_min

    if use_saved_history and strategy_id:
        try:
            from kis_trader.database.db_manager import get_db as _get_ext_db

            strict = False
            strict_lag = 1
            if strategy_id == "MOMENTUM":
                from kis_trader.backtest.momentum_backtest_common import (
                    momentum_backtest_universe_strict_enabled,
                    momentum_backtest_universe_strict_lag_min,
                    momentum_universe_exit_debounce_sec,
                )
                strict = momentum_backtest_universe_strict_enabled()
                strict_lag = momentum_backtest_universe_strict_lag_min()
                debounce_sec = momentum_universe_exit_debounce_sec()
            else:
                debounce_sec = 0

            history = _get_ext_db().get_universe_by_candle_time(
                strategy_id=strategy_id,
                start_ymd=start_ymd,
                end_ymd=end_ymd,
                strict=strict,
                strict_lag_minutes=strict_lag,
                exit_debounce_sec=debounce_sec,
            )
            if history:
                src = "history_strict" if strict else "history"
                return history, src, len(history), 1
        except Exception as exc:
            logger.debug(
                "유니버스 이력 조회 실패(strategy_id=%s): %s", strategy_id, exc,
            )

    if sim_kind and codes_candles:
        top_n = int(os.environ.get("UPDATE_UNIVERSE_TOP_N", "20"))
        min_score = float(os.environ.get("UPDATE_UNIVERSE_MIN_SCORE", "4.0"))
        if sim_kind == "momentum":
            from kis_trader.engine import momentum_engine as _me_uni
            slot_map = _me_uni.build_universe_simulation_momentum(
                codes_candles,
                top_n=top_n,
                min_score=min_score,
                scan_interval_min=sim_interval,
            )
        else:
            slot_map = se.build_universe_simulation(
                codes_candles,
                top_n=top_n,
                min_score=min_score,
                scan_interval_min=sim_interval,
            )
        return slot_map, "sim", 0, sim_interval

    return None, "all", 0, 1


def _parse_backtest_universe_arg(
    request,
    *,
    default: str = "history",
    sim_kind: Optional[str] = None,
) -> Tuple[bool, str, Optional[str]]:
    """
    백테스트 유니버스 쿼리 통일 — 모두 ``target_candidates_history`` 동일 테이블.

    - ``universe=history`` : 저장 후보 이력(슬롯·코드)
    - ``universe=sim``    : 시뮬 유니버스 (scalping_engine, sim_kind 필수)
    - ``universe=all``    : ws_candles 전 종목 (필터 없음)

    레거시 별칭: scalp_universe, mom_universe, tail_universe, bo_universe 등
    """
    raw = (
        request.args.get("universe")
        or request.args.get("scalp_universe")
        or request.args.get("mom_universe")
        or request.args.get("tail_universe")
        or request.args.get("tl_universe")
        or request.args.get("bo_universe")
        or request.args.get("breakout_universe")
        or request.args.get("bt_universe")
        or default
    ).strip().lower()
    if raw == "history":
        return True, "history", sim_kind
    if raw == "sim":
        return False, "sim", sim_kind
    return False, "all", None


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

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

    db = _db()
    try:
        # ── 전략 ID: kis_trader.utils.strategy_ids 와 동일 (SCALP/SHORT/UPDOW … + 구식명 접두어)
        like_pattern = strategy_like_pattern(strategy) or "SHORT%"
        params = [like_pattern, *EXCLUDED_STRATEGY_IDS]
        sql = (
            "SELECT * FROM trade_history WHERE strategy LIKE %s "
            f"AND strategy NOT IN ({_LEGACY_STRATEGY_SQL})"
        )
        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)
        closed_trades = cleaned

        # ── 보유 중(active_trades) = 매수만 된 포지션 — 목록에 표시, 손익 집계는 제외 ──
        open_rows = _list_active_trades_rows(
            None if (strategy or "").upper() == "ALL" else strategy,
            for_portfolio=False,
        )
        open_trades: List[Dict] = []
        now_dt = datetime.now()
        for row in open_rows:
            bd_raw = row.get("buy_date")
            if not bd_raw:
                continue
            bd_str = str(bd_raw)
            if start and bd_str < start + " 00:00:00":
                continue
            if end and bd_str > end + " 23:59:59":
                continue
            buy_px = float(row.get("avg_buy_price") or 0)
            cur_px = float(row.get("current_price") or buy_px)
            qty = int(row.get("current_qty") or 0)
            if qty <= 0:
                continue
            try:
                buy_time = datetime.strptime(bd_str[:19], "%Y-%m-%d %H:%M:%S")
                hold_min = int((now_dt - buy_time).total_seconds() / 60)
            except Exception:
                hold_min = 0
            unrealized = (cur_px - buy_px) * qty if buy_px > 0 else 0.0
            profit_rate = ((cur_px - buy_px) / buy_px * 100.0) if buy_px > 0 else 0.0
            open_trades.append({
                "code": row.get("code"),
                "name": row.get("name"),
                "strategy": row.get("strategy"),
                "buy_price": _int_display_price(buy_px),
                "sell_price": _int_display_price(cur_px),
                "qty": qty,
                "realized_pnl": None,
                "unrealized_pnl": round(unrealized),
                "profit_rate": round(profit_rate, 2),
                "hold_minutes": hold_min,
                "buy_date": bd_str,
                "sell_date": None,
                "sell_reason": "보유중",
                "is_open": True,
            })

        # 표시용: 보유(최신 매수일) + 청산 완료(매도일)
        trades = open_trades + closed_trades
        trades.sort(
            key=lambda t: str(t.get("sell_date") or t.get("buy_date") or ""),
            reverse=False,
        )

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

        # 누적 손익·승률 등은 청산 완료 건만 집계
        equity = []
        cum_pnl = 0.0
        for t in closed_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(closed_trades)
        wins    = [t for t in closed_trades if float(t.get("realized_pnl") or 0) > 0]
        losses  = [t for t in closed_trades if float(t.get("realized_pnl") or 0) < 0]
        total_pnl = sum(float(t.get("realized_pnl") or 0) for t in closed_trades)
        avg_hold  = (
            sum(float(t.get("hold_minutes") or 0) for t in closed_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 closed_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 closed_trades:
            r = t.get("sell_reason") or "기타"
            reasons[r] = reasons.get(r, 0) + 1

        # 일별 P&L
        daily: Dict[str, float] = {}
        for t in closed_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 closed_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]

        # 누적손익·누적% (청산 완료, 매도시각 순 — 백테 거래표와 동일)
        latest_env = db.get_latest_env()
        env_row = dict(latest_env["snapshot"]) if latest_env else {}
        total_budget_krw = 0.0
        try:
            from kis_trader.backtest.momentum_backtest_common import (
                resolve_momentum_portfolio_params,
            )
            from kis_trader.backtest.scalping_backtest_common import (
                resolve_scalp_portfolio_params,
            )
            strat_u = (strategy or "SCALP").upper()
            if strat_u == "MOMENTUM":
                port = resolve_momentum_portfolio_params(env_row, {})
            else:
                port = resolve_scalp_portfolio_params(
                    env_row, None, strategy=strat_u if strat_u != "ALL" else "SCALP",
                )
            total_budget_krw = float(port.get("total_budget_krw") or 0)
        except Exception:
            total_budget_krw = 0.0

        peak_cum = 0.0
        peak_cum_at = ""
        cum_trace = 0.0
        for t in sorted(closed_trades, key=lambda x: str(x.get("sell_date") or "")):
            cum_trace += float(t.get("realized_pnl") or 0)
            t["cum_pnl"] = int(round(cum_trace))
            t["cum_return_pct"] = (
                round(cum_trace / total_budget_krw * 100.0, 2)
                if total_budget_krw > 0 else 0.0
            )
            t["debug_tick"] = "실매체결"
            if cum_trace > peak_cum:
                peak_cum = cum_trace
                peak_cum_at = str(t.get("sell_date") or "")

        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),
                "peak_cum_pnl": round(peak_cum),
                "peak_cum_at": peak_cum_at[:19] if peak_cum_at else "",
            },
            "params": {
                "strategy": strategy,
                "total_budget_krw": round(total_budget_krw),
            },
            "meta": {
                "filtered_forced_rows": filtered_out,
                "closed_count": len(closed_trades),
                "open_count": len(open_trades),
            },
            "equity":      equity,
            "daily":       daily_list,
            "reasons":     reasons,
            "top_codes":   top_list,
            "trades":      _trades_recent_first(trades, 200),
        })
    finally:
        db.close()


# ────────────────────────────────────────────────────────────────────────────
# API: 실거래 당일 운영 대시보드 (전략별 + 합계)
# ────────────────────────────────────────────────────────────────────────────

# ※ SCALP·DBBAND·RANGE_BREAK 는 숨김 전략(strategy_ids.HIDDEN_STRATEGY_IDS) → 대시보드 집계 제외.
_ACTUAL_DASHBOARD_STRATEGIES: Tuple[str, ...] = (
    "SHORT",
    "MOMENTUM",
    "UPDOW",
    "BREAKOUT",
)

_ACTUAL_DASHBOARD_LABELS: Dict[str, str] = {
    "SCALP": "스캘핑",
    "SHORT": "꼬리잡기",
    "MOMENTUM": "모멘텀",
    "UPDOW": "60분 하락매수",
    "BREAKOUT": "돌파",
    "DBBAND": "더블BB",
}

_STRATEGY_ENABLED_DEFAULTS: Dict[str, bool] = {
    "SCALP": True,
    "SHORT": True,
    "MOMENTUM": False,
    "UPDOW": False,
    "BREAKOUT": False,
    "DBBAND": False,
}


def _portfolio_strategy_key(strategy_id: str) -> str:
    """backtest_portfolio_common STRATEGY_PORTFOLIO_KEYS (SHORT → TAIL)."""
    s = (strategy_id or "").upper()
    if s == "SHORT":
        return "TAIL"
    return s


def _is_forced_ghost_trade(row: Dict[str, Any]) -> bool:
    reason = str(row.get("sell_reason") or "")
    try:
        sell_price = float(row.get("sell_price") or 0)
    except Exception:
        sell_price = 0.0
    is_forced = ("잔고없음(강제정리)" in reason) or ("잔고동기화(외부매도)" in reason)
    return is_forced and sell_price <= 0


def _day_bounds_kst(day_iso: str) -> Tuple[str, str]:
    d = (day_iso or "").strip()[:10]
    if len(d) != 10:
        d = datetime.now().strftime("%Y-%m-%d")
    return f"{d} 00:00:00", f"{d} 23:59:59"


def _strategy_enabled_from_snapshot(snap: Dict[str, str], strategy_id: str) -> bool:
    from kis_trader.utils.env import get_env_bool

    key = f"STRATEGY_{strategy_id}_ENABLED"
    default = _STRATEGY_ENABLED_DEFAULTS.get(strategy_id, False)
    # 스냅샷이 있으면 DB 값 우선 (get_env_bool은 os.environ 폴백 포함)
    if snap and key in snap:
        raw = str(snap.get(key) or "").strip().lower()
        if raw in ("1", "true", "yes", "on"):
            return True
        if raw in ("0", "false", "no", "off"):
            return False
    return get_env_bool(key, default)


def _strategy_budget_limit_krw(snap: Dict[str, str], strategy_id: str) -> float:
    from kis_trader.backtest.backtest_portfolio_common import resolve_portfolio_params

    pf_key = _portfolio_strategy_key(strategy_id)
    pf = resolve_portfolio_params(snap, strategy=pf_key)
    return float(pf.get("total_budget_krw") or 0)


def _empty_dashboard_row(strategy_id: str, snap: Dict[str, str]) -> Dict[str, Any]:
    limit = _strategy_budget_limit_krw(snap, strategy_id)
    return {
        "strategy_id": strategy_id,
        "label": _ACTUAL_DASHBOARD_LABELS.get(strategy_id, strategy_id),
        "enabled": _strategy_enabled_from_snapshot(snap, strategy_id),
        "buy_turnover_krw": 0,
        "sell_turnover_krw": 0,
        "turnover_krw": 0,
        "closed_trades": 0,
        "open_positions": 0,
        "realized_pnl_krw": 0,
        "budget_limit_krw": round(limit),
        "budget_used_krw": 0,
        "budget_now_krw": 0,
        "budget_usage_pct": 0.0,
        "return_pct": 0.0,
    }


def _closed_buy_invested_krw(row: Dict[str, Any]) -> float:
    """청산 건 매수 투입금(평단×수량) — 당일 뽀찌 피크 계산용."""
    try:
        qty = int(row.get("qty") or 0)
        buy_px = float(row.get("buy_price") or 0)
    except Exception:
        return 0.0
    if qty > 0 and buy_px > 0:
        return float(buy_px * qty)
    return 0.0


def _open_invested_krw(row: Dict[str, Any]) -> float:
    """보유 중 투입금 — total_invested 우선."""
    try:
        invested = float(row.get("total_invested") or 0)
    except Exception:
        invested = 0.0
    if invested > 0:
        return invested
    try:
        qty = int(row.get("current_qty") or 0)
        avg_px = float(row.get("avg_buy_price") or 0)
        if qty > 0 and avg_px > 0:
            return float(avg_px * qty)
    except Exception:
        pass
    return 0.0


def _daily_peak_budget_krw(
    strategy_id: str,
    day_start: str,
    day_end: str,
    closed_overlap: List[Dict[str, Any]],
    open_rows: List[Dict[str, Any]],
) -> int:
    """
    당일(KST) 전략별 최대 동시 투입(뽀찌 피크).
    - 장 시작 시 이미 보유(전일 매수) → initial
    - 당일 매수 이벤트 +, 당일 매도 이벤트 −
  """
    events: List[Tuple[str, float]] = []
    initial = 0.0

    for t in closed_overlap:
        if canonical_strategy_id(t.get("strategy")) != strategy_id:
            continue
        if _is_forced_ghost_trade(t):
            continue
        amt = _closed_buy_invested_krw(t)
        if amt <= 0:
            continue
        bd = str(t.get("buy_date") or "")
        sd = str(t.get("sell_date") or "")
        if bd < day_start and sd >= day_start:
            initial += amt
        if day_start <= bd <= day_end:
            events.append((bd, amt))
        if day_start <= sd <= day_end:
            events.append((sd, -amt))

    for o in open_rows:
        if canonical_strategy_id(o.get("strategy")) != strategy_id:
            continue
        amt = _open_invested_krw(o)
        if amt <= 0:
            continue
        bd = str(o.get("buy_date") or "")
        if bd < day_start:
            initial += amt
        elif day_start <= bd <= day_end:
            events.append((bd, amt))

    events.sort(key=lambda x: x[0])
    running = initial
    peak = initial
    for _, delta in events:
        running += delta
        if running > peak:
            peak = running
    return int(round(max(peak, 0)))


def _build_actual_dashboard(db: TradeDB, day_iso: str) -> Dict[str, Any]:
    """당일(KST) 전략별 거래대금·뽀찌 사용·실현수익률 — 백테 bot_pct 분모와 동일(운용한도)."""
    snap = db.get_merged_env_snapshot() or {}
    day_start, day_end = _day_bounds_kst(day_iso)
    day = day_start[:10]

    rows_by_sid: Dict[str, Dict[str, Any]] = {
        sid: _empty_dashboard_row(sid, snap) for sid in _ACTUAL_DASHBOARD_STRATEGIES
    }

    ex_params = list(EXCLUDED_STRATEGY_IDS)
    sql_closed = (
        "SELECT * FROM trade_history WHERE strategy NOT IN ("
        + _LEGACY_STRATEGY_SQL
        + ") AND ((sell_date >= %s AND sell_date <= %s) "
        "OR (buy_date >= %s AND buy_date <= %s))"
    )
    params = ex_params + [day_start, day_end, day_start, day_end]
    closed_raw = db.conn.execute(sql_closed, params).fetchall()

    sql_overlap = (
        "SELECT * FROM trade_history WHERE strategy NOT IN ("
        + _LEGACY_STRATEGY_SQL
        + ") AND buy_date <= %s AND sell_date >= %s"
    )
    closed_overlap = [
        dict(r)
        for r in db.conn.execute(sql_overlap, ex_params + [day_end, day_start]).fetchall()
    ]

    for raw in closed_raw:
        t = dict(raw)
        if _is_forced_ghost_trade(t):
            continue
        sid = canonical_strategy_id(t.get("strategy"))
        if sid not in rows_by_sid:
            continue
        row = rows_by_sid[sid]
        try:
            qty = int(t.get("qty") or 0)
        except Exception:
            qty = 0
        try:
            buy_px = float(t.get("buy_price") or 0)
            sell_px = float(t.get("sell_price") or 0)
        except Exception:
            buy_px, sell_px = 0.0, 0.0
        buy_notional = buy_px * qty if qty > 0 and buy_px > 0 else 0.0
        sell_notional = sell_px * qty if qty > 0 and sell_px > 0 else 0.0
        bd = str(t.get("buy_date") or "")
        sd = str(t.get("sell_date") or "")
        if day_start <= bd <= day_end:
            row["buy_turnover_krw"] = int(row["buy_turnover_krw"]) + int(round(buy_notional))
        if day_start <= sd <= day_end:
            row["sell_turnover_krw"] = int(row["sell_turnover_krw"]) + int(round(sell_notional))
            row["closed_trades"] = int(row["closed_trades"]) + 1
            row["realized_pnl_krw"] = int(row["realized_pnl_krw"]) + int(
                round(float(t.get("realized_pnl") or 0))
            )

    open_rows = _list_active_trades_rows(None, for_portfolio=False)
    for orow in open_rows:
        sid = canonical_strategy_id(orow.get("strategy"))
        if sid not in rows_by_sid:
            continue
        row = rows_by_sid[sid]
        try:
            invested = float(orow.get("total_invested") or 0)
        except Exception:
            invested = 0.0
        if invested <= 0:
            try:
                qty_o = int(orow.get("current_qty") or 0)
                avg_px = float(orow.get("avg_buy_price") or 0)
                invested = avg_px * qty_o
            except Exception:
                invested = 0.0
        if invested > 0:
            row["budget_now_krw"] = int(row["budget_now_krw"]) + int(round(invested))
            row["open_positions"] = int(row["open_positions"]) + 1
        bd = str(orow.get("buy_date") or "")
        if day_start <= bd <= day_end and invested > 0:
            row["buy_turnover_krw"] = int(row["buy_turnover_krw"]) + int(round(invested))

    strategy_list: List[Dict[str, Any]] = []
    for sid in _ACTUAL_DASHBOARD_STRATEGIES:
        row = rows_by_sid[sid]
        row["turnover_krw"] = int(row["buy_turnover_krw"]) + int(row["sell_turnover_krw"])
        peak = _daily_peak_budget_krw(sid, day_start, day_end, closed_overlap, open_rows)
        row["budget_used_krw"] = peak
        limit = float(row["budget_limit_krw"] or 0)
        pnl = float(row["realized_pnl_krw"] or 0)
        row["budget_usage_pct"] = round(peak / limit * 100, 2) if limit > 0 else 0.0
        row["return_pct"] = round(pnl / limit * 100, 3) if limit > 0 else 0.0
        strategy_list.append(row)

    total_limit = sum(
        float(r["budget_limit_krw"] or 0)
        for r in strategy_list
        if r.get("enabled")
    )
    total_used = sum(float(r["budget_used_krw"] or 0) for r in strategy_list)
    total_now = sum(float(r["budget_now_krw"] or 0) for r in strategy_list)
    total_pnl = sum(float(r["realized_pnl_krw"] or 0) for r in strategy_list)
    total_buy = sum(int(r["buy_turnover_krw"] or 0) for r in strategy_list)
    total_sell = sum(int(r["sell_turnover_krw"] or 0) for r in strategy_list)
    total_turnover = total_buy + total_sell

    totals = {
        "buy_turnover_krw": total_buy,
        "sell_turnover_krw": total_sell,
        "turnover_krw": total_turnover,
        "closed_trades": sum(int(r["closed_trades"] or 0) for r in strategy_list),
        "open_positions": sum(int(r["open_positions"] or 0) for r in strategy_list),
        "realized_pnl_krw": int(round(total_pnl)),
        "budget_limit_krw": int(round(total_limit)),
        "budget_used_krw": int(round(total_used)),
        "budget_now_krw": int(round(total_now)),
        "budget_usage_pct": round(total_used / total_limit * 100, 2) if total_limit > 0 else 0.0,
        "return_pct": round(total_pnl / total_limit * 100, 3) if total_limit > 0 else 0.0,
    }

    return {
        "date": day,
        "as_of": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
        "strategies": strategy_list,
        "totals": totals,
        "notes": {
            "turnover": "당일 매수·매도 체결금액 합(매수일·매도일 각각 집계, 동일일 왕복 시 양쪽 합산)",
            "budget_used": "당일 최대 동시 투입(뽀찌 피크) — 매수·매도 시각 순으로 재구성, 청산 후에도 당일 사용량 유지",
            "budget_now": "현재 보유 중 투입금(active_trades) — 실시간 스냅샷",
            "return_pct": "당일 실현손익 ÷ ON 전략 운용한도 합 (백테 bot_pct와 동일 분모)",
        },
    }


@app.route("/api/actual/dashboard", methods=["GET"])
def api_actual_dashboard():
    day = (request.args.get("date") or "").strip()[:10]
    if not day:
        day = datetime.now().strftime("%Y-%m-%d")
    db = _db()
    try:
        payload = _build_actual_dashboard(db, day)
        return jsonify({"ok": True, **payload})
    except Exception as e:
        logger.exception("actual dashboard 실패")
        return jsonify({"ok": False, "error": str(e)}), 500
    finally:
        db.close()


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

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


def _backtest_period_days(start: str, end: str, fallback: int = 1) -> int:
    """백테스트 기간 일수(시작·종료일 포함)."""
    try:
        s = (start or "").replace("-", "")[:8]
        e = (end or "").replace("-", "")[:8]
        if len(s) == 8 and len(e) == 8:
            d0 = datetime.strptime(s, "%Y%m%d")
            d1 = datetime.strptime(e, "%Y%m%d")
            return max(1, (d1 - d0).days + 1)
    except (ValueError, TypeError):
        pass
    return max(1, fallback)


from kis_trader.engine import scalping_engine as se
from kis_trader.engine import momentum_engine as me
from kis_trader.backtest import tail_backtest_common as tbc
from kis_trader.backtest import momentum_backtest_common as mbc
from kis_trader.backtest import breakout_backtest_common as bbc
from kis_trader.backtest import range_break_backtest_common as rbc
from kis_trader.backtest import scalping_backtest_common as sbc
# 돌파매매 — strategies 안의 모듈 함수를 그대로 재사용 (실매매-백테 100% 일치)
from kis_trader.strategies.range_break import range_break_ui_to_engine_params
from kis_trader.strategies.breakout import (
    breakout_entry_mode,
    breakout_invest_amount_krw,
    breakout_ui_to_engine_params,
    check_buy_signal_breakout_live as _bo_check_buy,
    check_sell_signal_breakout_live as _bo_check_sell,
    normalize_breakout_max_loss_krw,
    run_breakout_backtest as _bo_run_backtest,
)
from kis_trader.strategies.base import is_strategy_eod_bar


def _momentum_ui_defaults_from_db(_d: Dict[str, Any]) -> Dict[str, Any]:
    """config_momentum + env_config 병합에서 모멘텀 웹·백테 초기값.

    - MOMENTUM_* 우선 → SCALP_MOM_* / SCALP_* / ``get_scalping_defaults_from_db`` 폴백.
    - ``insert_env_snapshot`` / param_search_apply → config_momentum 저장.
    """
    env: Dict[str, Any] = _strategy_env("MOMENTUM")

    def pickf(keys: Tuple[str, ...], default: float) -> float:
        for k in keys:
            v = env.get(k)
            if v not in (None, "", "None"):
                try:
                    return float(v)
                except (ValueError, TypeError):
                    continue
        return float(default)

    def picki(keys: Tuple[str, ...], default: int) -> int:
        for k in keys:
            v = env.get(k)
            if v not in (None, "", "None"):
                try:
                    return int(float(v))
                except (ValueError, TypeError):
                    continue
        return int(default)

    def pick_sl_tp_pct(keys: Tuple[str, ...], default_pct: float) -> float:
        """DB 소수(0.015) 또는 퍼센트(1.5) → 화면·쿼리스트링용 퍼센트 숫자."""
        for k in keys:
            v = env.get(k)
            if v not in (None, "", "None"):
                try:
                    x = abs(float(v))
                    if x == 0:
                        return 0.0
                    return x if x >= 0.5 else round(x * 100, 3)
                except (ValueError, TypeError):
                    continue
        return float(default_pct)

    def pick_trail_ui(col: str, default_ratio: float) -> float:
        """DB 소수 비율(0.02=2%) 또는 퍼센트 숫자(2) → UI 퍼센트 (pick_sl_tp_pct 와 동일 임계)."""
        v = env.get(col)
        if v not in (None, "", "None"):
            try:
                x = abs(float(v))
                if x == 0:
                    return 0.0
                return x if x >= 0.5 else round(x * 100.0, 6)
            except (ValueError, TypeError):
                pass
        return round(float(default_ratio) * 100, 3)

    sl_pct_disp = pick_sl_tp_pct(
        ("MOMENTUM_STOP_LOSS_PCT", "SCALP_STOP_LOSS_PCT"), 1.5,
    )
    tp_pct_disp = pick_sl_tp_pct(
        ("MOMENTUM_TAKE_PROFIT_PCT", "SCALP_TAKE_PROFIT_PCT"), 2.5,
    )
    tp_max_disp = pick_sl_tp_pct(
        ("MOMENTUM_TP_MAX_PCT", "SCALP_TP_MAX_PCT"), 2.0,
    )

    high_chase = pickf(
        ("HIGH_CHASE_THR", "SCALP_HIGH_PRICE_CHASE_THRESHOLD", "HIGH_PRICE_CHASE_THRESHOLD"),
        float(_d.get("high_chase_thr", 0.96)),
    )
    max_daily_chg = pickf(
        ("MAX_DAILY_CHG", "SCALP_MAX_DAILY_CHANGE_PCT", "MAX_DAILY_CHANGE_PCT"),
        float(_d.get("max_daily_chg", 20.0)),
    )
    min_price = pickf(
        ("MOMENTUM_MIN_PRICE", "MIN_STOCK_PRICE", "SCALP_MIN_PRICE", "MIN_PRICE_TAIL"),
        float(_d.get("min_price", 1000.0)),
    )
    max_loss_krw = picki(
        ("MOMENTUM_MAX_LOSS_PER_TRADE_KRW", "SCALP_MAX_LOSS_PER_TRADE_KRW", "MAX_LOSS_PER_TRADE_KRW"),
        int(_d.get("max_loss_krw", 200000)),
    )
    # DB SCALP_MIN_PROFIT_PCT 는 보통 0.2(=0.2% 표시) / 엔진 min_margin 은 비율
    min_margin_disp = round(float(_d.get("min_margin", 0.002)) * 100, 3)
    _mmp = env.get("SCALP_MIN_PROFIT_PCT")
    if _mmp not in (None, "", "None"):
        try:
            min_margin_disp = float(_mmp)
        except (ValueError, TypeError):
            pass
    _mommp = env.get("MOMENTUM_MIN_PROFIT_PCT")
    if _mommp not in (None, "", "None"):
        try:
            min_margin_disp = float(_mommp)
        except (ValueError, TypeError):
            pass

    _udf = env.get("SCALP_USE_DEFENSE_FILTERS")
    if _udf not in (None, "", "None"):
        use_def = str(_udf).strip().lower() in ("1", "true", "y", "yes", "on")
    else:
        use_def = bool(_d.get("use_defense_filters", True))

    slot_money = pickf(
        ("MOMENTUM_SLOT_MONEY", "SLOT_MONEY_DEFAULT"),
        float(_d.get("slot_money", 200_000)),
    )
    mom_slots = picki(("MOMENTUM_MAX_STOCKS", "SCALP_MAX_STOCKS"), 20)
    total_budget_krw = int(
        float(
            pickf(
                ("MOMENTUM_TOTAL_BUDGET_KRW", "SCALP_TOTAL_BUDGET_KRW", "SHORT_TOTAL_BUDGET_KRW"),
                2_000_000.0,
            )
            or 0
        )
    )
    # 백테·파라serch 와 동일 포트폴리오 해석 (웹 입력 = CLI --slot-money/--max-stocks/--total-budget)
    try:
        portfolio = sbc.resolve_scalp_portfolio_params(
            env, _d, strategy="MOMENTUM",
            slot_money=slot_money, max_stocks=mom_slots, total_budget_krw=total_budget_krw or None,
        )
        slot_money = float(portfolio["slot_money"])
        mom_slots = int(portfolio["max_stocks"])
        total_budget_krw = int(float(portfolio["total_budget_krw"]))
    except Exception:
        portfolio = {}
    portfolio_ui_warning = None
    # max_loss/손절% 로 자동 계산된 1천만 등이 DB에 저장된 경우 — 1회투자 > 총한도 → 백테 0건
    if total_budget_krw > 0 and slot_money > total_budget_krw:
        from kis_trader.utils.env import get_env_int
        portfolio_ui_warning = (
            f"DB 1회투자({slot_money:,.0f}원) > 총운용한도({total_budget_krw:,.0f}원). "
            "max_loss÷손절% 자동계산값이 잘못 들어갔을 수 있어 "
            "파라serch CLI 기본(1회20만·동시20·한도200만)으로 표시를 교정했습니다."
        )
        slot_money = float(get_env_int("MOMENTUM_SEARCH_SLOT_MONEY_KRW", 200_000))
        mom_slots = int(get_env_int("MOMENTUM_SEARCH_MAX_STOCKS", 20))
        total_budget_krw = int(get_env_int("MOMENTUM_SEARCH_TOTAL_BUDGET_KRW", 2_000_000))
    # 시가 대비 과열/과매도 컷 — ``param_search_momentum`` coarse 그리드와 동일 개념.
    # DB 미설정 시 max=30 (%): coarse 탐색이 끝물 방지에 쓰는 값과 웹 백테를 맞춤 (999=OFF).
    mom_max_open = pickf(("MOMENTUM_MAX_FROM_OPEN_PCT",), 30.0)
    mom_min_open = pickf(("MOMENTUM_MIN_FROM_OPEN_PCT",), -999.0)

    def pick_bool(keys: Tuple[str, ...], default: bool) -> bool:
        for k in keys:
            v = env.get(k)
            if v not in (None, "", "None"):
                return str(v).strip().lower() in ("1", "true", "t", "y", "yes", "on")
        return bool(default)

    use_ema_filter = pick_bool(("MOMENTUM_USE_EMA_FILTER",), True)
    use_rsi_max_filter = pick_bool(("MOMENTUM_USE_RSI_MAX_FILTER",), False)
    pattern_breakout = pick_bool(("MOMENTUM_PATTERN_BREAKOUT",), True)
    pattern_pullback = pick_bool(("MOMENTUM_PATTERN_PULLBACK",), True)
    ema_fast_period = picki(("MOMENTUM_EMA_FAST_PERIOD",), 9)
    ema_slow_period = picki(("MOMENTUM_EMA_SLOW_PERIOD",), 21)
    chase_lookback_min = picki(("MOMENTUM_CHASE_LOOKBACK_MIN",), 10)
    pullback_lookback_min = picki(("MOMENTUM_PULLBACK_LOOKBACK_MIN",), 15)
    pullback_min_pct = pickf(("MOMENTUM_PULLBACK_MIN_PCT",), 0.3)
    pullback_max_pct = pickf(("MOMENTUM_PULLBACK_MAX_PCT",), 3.0)
    setup_vol_max_mult = pickf(("MOMENTUM_SETUP_VOL_MAX_MULT",), 0.8)
    setup_bear_bars_min = picki(("MOMENTUM_SETUP_BEAR_BARS_MIN",), 1)

    def pick_shoulder_frac(keys: Tuple[str, ...], default_ratio: float) -> float:
        for k in keys:
            v = env.get(k)
            if v not in (None, "", "None"):
                try:
                    x = abs(float(v))
                    if x == 0:
                        return 0.0
                    return round(x * 100, 3) if x < 0.5 else round(x, 3)
                except (ValueError, TypeError):
                    continue
        dr = abs(float(default_ratio))
        return round(dr * 100, 3) if dr < 0.5 else round(dr, 3)

    # ── 호가/프로그램 필터 — 글로벌 vs 모멘텀 전용 분해 (웹 안내 표시용) ──
    # 우선순위: 모멘텀 전용키에 값이 있으면 그 값, 비어있으면 글로벌키를 상속.
    # (실매·백테 모두 orderbook_filter.orderbook_filter_enabled 가 같은 규칙)
    def _filt_truthy(raw: Any) -> bool:
        return str(raw).strip().lower() in ("1", "true", "t", "y", "yes", "on")

    _ob_strat_raw = env.get("MOMENTUM_ORDERBOOK_FILTER_ENABLED")
    _pg_strat_raw = env.get("MOMENTUM_PROGRAM_FILTER_ENABLED")
    _ob_strat_set = _ob_strat_raw not in (None, "", "None")
    _pg_strat_set = _pg_strat_raw not in (None, "", "None")

    return {
        "mom_rsi_min": pickf(("MOMENTUM_RSI_MIN", "SCALP_MOM_RSI_MIN"), 50.0),
        "mom_rsi_max": pickf(("MOMENTUM_RSI_MAX", "SCALP_MOM_RSI_MAX"), 80.0),
        "mom_vol_mult": pickf(("MOMENTUM_VOL_MULT", "SCALP_MOM_VOL_MULT"), 1.5),
        "mom_vol_win": picki(("MOMENTUM_VOL_WIN", "SCALP_MOM_VOL_WIN"), 5),
        "mom_time_end_hm": picki(("MOMENTUM_TIME_END_HM", "SCALP_MOM_TIME_END_HM"), 1430),
        "mom_time_start_hm": picki(
            ("MOMENTUM_TIME_START", "SCALP_TIME_START", "TIME_START"), 900,
        ),
        "sl_pct": sl_pct_disp,
        "tp_pct": tp_pct_disp,
        "tp_max_pct": tp_max_disp,
        "shoulder_min_high": pick_shoulder_frac(
            ("MOMENTUM_SHOULDER_MIN_HIGH_PCT", "SCALP_SHOULDER_MIN_HIGH_PCT", "SHOULDER_MIN_HIGH_PCT"),
            float(_d.get("shoulder_min_high", 0.005)),
        ),
        "shoulder_cut_pct": pick_shoulder_frac(
            ("MOMENTUM_SHOULDER_CUT_PCT", "SCALP_SHOULDER_CUT_PCT", "SHOULDER_CUT_PCT"),
            float(_d.get("shoulder_cut_pct", 0.003)),
        ),
        "trail_trigger": pick_trail_ui("SCALP_ATR_UP_MULT", float(_d.get("trail_trigger", 0.007))),
        "trail_stop": pick_trail_ui("SCALP_ATR_DOWN_MULT", float(_d.get("trail_stop", 0.004))),
        "cooldown_min": (
            max(0, int(float(env.get("SCALP_COOLDOWN_SEC"))) // 60)
            if env.get("SCALP_COOLDOWN_SEC") not in (None, "", "None")
            else float(_d.get("cooldown_min", 10))
        ),
        "max_daily": picki(("MOMENTUM_MAX_DAILY", "SCALP_MAX_DAILY"), 5),
        "slot_money": slot_money,
        "mom_slots": mom_slots,
        "total_budget_krw": total_budget_krw,
        "portfolio_ui_warning": portfolio_ui_warning,
        "high_chase_thr": high_chase,
        "max_daily_chg": max_daily_chg,
        "min_price": min_price,
        "max_loss_krw": max_loss_krw,
        "min_margin": min_margin_disp,
        "use_defense_filters": use_def,
        "mom_max_from_open_pct": mom_max_open,
        "mom_min_from_open_pct": mom_min_open,
        "use_ema_filter": use_ema_filter,
        "use_rsi_max_filter": use_rsi_max_filter,
        "pattern_breakout": pattern_breakout,
        "pattern_pullback": pattern_pullback,
        "chase_lookback_min": chase_lookback_min,
        "pullback_lookback_min": pullback_lookback_min,
        "pullback_min_pct": pullback_min_pct,
        "pullback_max_pct": pullback_max_pct,
        "setup_vol_max_mult": setup_vol_max_mult,
        "setup_bear_bars_min": setup_bear_bars_min,
        "ema_fast_period": ema_fast_period,
        "ema_slow_period": ema_slow_period,
        "ob_filter_enabled": _strategy_trigger_filter_enabled(
            env, prefix="MOMENTUM", kind="ORDERBOOK", global_key="ORDERBOOK_FILTER_ENABLED",
        ),
        "pg_filter_enabled": _strategy_trigger_filter_enabled(
            env, prefix="MOMENTUM", kind="PROGRAM", global_key="PROGRAM_FILTER_ENABLED",
        ),
        # 글로벌값 / 전용 명시여부 — 웹 안내문("글로벌 X · 실매 Y")용 (체크값엔 영향 없음)
        "ob_global_enabled": _filt_truthy(env.get("ORDERBOOK_FILTER_ENABLED")),
        "ob_strategy_explicit": _ob_strat_set,
        "pg_global_enabled": _filt_truthy(env.get("PROGRAM_FILTER_ENABLED")),
        "pg_strategy_explicit": _pg_strat_set,
        # 호가 스프레드 상한(%) — 0.45 = 0.45%. kiwoom_0d 본체 재계산용 (6/25~ 유효)
        "max_spread_pct": pickf(
            ("MOMENTUM_ORDERBOOK_MAX_SPREAD_PCT", "ORDERBOOK_MAX_SPREAD_PCT"),
            0.45,
        ),
        "eod_enabled": pick_bool(("MOMENTUM_EOD_ENABLED",), True),
        "eod_hm": (
            str(env.get("MOMENTUM_EOD_HM") or "15:25").strip()
            if env.get("MOMENTUM_EOD_HM") not in (None, "", "None")
            else "15:25"
        ),
        **_momentum_exit_ui_from_engine(),
    }


def _momentum_ratio_to_ui_pct(val: Any, default: float = 0.0) -> float:
    """엔진 비율(0.015) 또는 UI 퍼센트(1.5) → 폼 표시 %."""
    if val is None or val == "":
        return float(default)
    try:
        x = abs(float(val))
        if x == 0:
            return 0.0
        return round(x * 100, 3) if x < 0.5 else round(x, 3)
    except (ValueError, TypeError):
        return float(default)


def _momentum_exit_ui_from_engine() -> Dict[str, Any]:
    """전용 청산 필드 — momentum_engine 단일 소스."""
    try:
        d = me.get_momentum_defaults_from_db()
    except Exception:
        d = {}
    return {
        "trail_pct": _momentum_ratio_to_ui_pct(d.get("trail_pct"), 0.0),
        "trail_arm_pct": _momentum_ratio_to_ui_pct(d.get("trail_arm_pct"), 0.0),
        "max_hold_bars": int(d.get("max_hold_bars") or 0),
        "ratchet_tiers": str(d.get("ratchet_tiers") or ""),
        "use_high_chase_filter": bool(d.get("use_high_chase_filter", False)),
        "use_daily_range_filter": bool(d.get("use_daily_range_filter", False)),
        "use_ema_filter": bool(d.get("use_ema_filter", True)),
        "use_rsi_max_filter": bool(d.get("use_rsi_max_filter", False)),
        "pattern_breakout": bool(d.get("pattern_breakout", True)),
        "pattern_pullback": bool(d.get("pattern_pullback", True)),
        "chase_lookback_min": int(d.get("chase_lookback_min") or 10),
        "pullback_lookback_min": int(d.get("pullback_lookback_min") or 15),
        "pullback_min_pct": float(d.get("pullback_min_pct") or 0.3),
        "pullback_max_pct": float(d.get("pullback_max_pct") or 3.0),
        "ema_fast_period": int(d.get("ema_fast_period") or 9),
        "ema_slow_period": int(d.get("ema_slow_period") or 21),
    }


def _momentum_engine_dict_to_ui(
    d: Dict[str, Any],
    snap: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
    """momentum_engine defaults 또는 파라서치 merged → 웹 입력란 값."""
    snap = snap or {}
    if not d:
        return {}

    def _ui(k: str, default: Any = None) -> Any:
        v = d.get(k)
        return default if v in (None, "") else v

    slot_raw = snap.get("MOMENTUM_SLOT_MONEY") or d.get("slot_money")
    slots_raw = snap.get("MOMENTUM_MAX_STOCKS") or d.get("max_stocks")
    budget_raw = snap.get("MOMENTUM_TOTAL_BUDGET_KRW") or d.get("total_budget_krw")

    out: Dict[str, Any] = {
        "mom_rsi_min": _ui("mom_rsi_min"),
        "mom_rsi_max": _ui("mom_rsi_max"),
        "mom_vol_mult": _ui("mom_vol_mult"),
        "mom_vol_win": _ui("mom_vol_win"),
        "mom_time_end_hm": _ui("mom_time_end_hm") or _ui("mom_time_end"),
        "mom_time_start_hm": _ui("time_start_hm") or _ui("time_start"),
        "sl_pct": _momentum_ratio_to_ui_pct(d.get("sl_pct")),
        "tp_pct": _momentum_ratio_to_ui_pct(d.get("tp_pct")),
        "tp_max_pct": _momentum_ratio_to_ui_pct(d.get("tp_max_pct")),
        "shoulder_min_high": _momentum_ratio_to_ui_pct(d.get("shoulder_min_high")),
        "shoulder_cut_pct": _momentum_ratio_to_ui_pct(d.get("shoulder_cut_pct")),
        "trail_pct": _momentum_ratio_to_ui_pct(
            d.get("trail_pct") if d.get("trail_pct") is not None else d.get("trail_trigger"),
        ),
        "trail_arm_pct": _momentum_ratio_to_ui_pct(
            d.get("trail_arm_pct") if d.get("trail_arm_pct") is not None else d.get("trail_stop"),
        ),
        "max_hold_bars": int(float(d.get("max_hold_bars") or 0)),
        "ratchet_tiers": str(d.get("ratchet_tiers") or ""),
        "cooldown_min": d.get("cooldown_min"),
        "max_daily": d.get("max_daily"),
        "high_chase_thr": d.get("high_chase_thr"),
        "max_daily_chg": d.get("max_daily_chg"),
        "min_price": d.get("min_price"),
        "max_loss_krw": d.get("max_loss_krw"),
        "min_margin": d.get("min_margin"),
        "mom_max_from_open_pct": d.get("mom_max_from_open_pct"),
        "mom_min_from_open_pct": d.get("mom_min_from_open_pct"),
        "use_defense_filters": d.get("use_defense_filters"),
        "use_high_chase_filter": d.get("use_high_chase_filter"),
        "use_daily_range_filter": d.get("use_daily_range_filter"),
        "use_ema_filter": d.get("use_ema_filter"),
        "use_rsi_max_filter": d.get("use_rsi_max_filter"),
        "pattern_breakout": d.get("pattern_breakout"),
        "pattern_pullback": d.get("pattern_pullback"),
        "chase_lookback_min": d.get("chase_lookback_min"),
        "pullback_lookback_min": d.get("pullback_lookback_min"),
        "pullback_min_pct": d.get("pullback_min_pct"),
        "pullback_max_pct": d.get("pullback_max_pct"),
        "ema_fast_period": d.get("ema_fast_period"),
        "ema_slow_period": d.get("ema_slow_period"),
    }
    if slot_raw not in (None, ""):
        out["slot_money"] = int(float(slot_raw))
    if slots_raw not in (None, ""):
        out["mom_slots"] = int(float(slots_raw))
    if budget_raw not in (None, ""):
        out["total_budget_krw"] = int(float(budget_raw))
    return out


def _load_momentum_search_json(path: Optional[str] = None) -> Tuple[Optional[str], Optional[Dict[str, Any]]]:
    """최신 search_momentum_*.json 또는 지정 경로 로드."""
    if path and os.path.isfile(path):
        try:
            with open(path, "r", encoding="utf-8") as f:
                return path, json.load(f)
        except (OSError, json.JSONDecodeError):
            return path, None
    try:
        from kis_trader.backtest.param_search_momentum import _latest_json
        p = _latest_json("search_momentum_")
    except ImportError:
        p = None
    if not p or not os.path.isfile(p):
        return None, None
    try:
        with open(p, "r", encoding="utf-8") as f:
            return p, json.load(f)
    except (OSError, json.JSONDecodeError):
        return p, None


def _scalp_ui_defaults_from_db() -> Dict[str, Any]:
    """config_scalp + env_config 병합에서 스캘핑 reversal 웹·백테 초기값."""
    _d = se.get_scalping_defaults_from_db()
    env = _strategy_env("SCALP")

    def pct_ui(db_key: str, ratio_default: float) -> float:
        v = env.get(db_key)
        if v not in (None, "", "None"):
            try:
                x = abs(float(v))
                if x == 0:
                    return 0.0
                return x if x >= 0.5 else round(x * 100, 3)
            except (ValueError, TypeError):
                pass
        r = abs(float(ratio_default))
        return round(r * 100, 3) if r < 0.5 else round(r, 3)

    def shoulder_ui(ratio_default: float) -> float:
        for k in ("SCALP_SHOULDER_MIN_HIGH_PCT", "SHOULDER_MIN_HIGH_PCT"):
            v = env.get(k)
            if v not in (None, "", "None"):
                try:
                    x = abs(float(v))
                    return round(x * 100, 3) if x < 0.5 else round(x, 3)
                except (ValueError, TypeError):
                    continue
        r = abs(float(ratio_default))
        return round(r * 100, 3) if r < 0.5 else round(r, 3)

    def shoulder_cut_ui(ratio_default: float) -> float:
        for k in ("SCALP_SHOULDER_CUT_PCT", "SHOULDER_CUT_PCT"):
            v = env.get(k)
            if v not in (None, "", "None"):
                try:
                    x = abs(float(v))
                    return round(x * 100, 3) if x < 0.5 else round(x, 3)
                except (ValueError, TypeError):
                    continue
        r = abs(float(ratio_default))
        return round(r * 100, 3) if r < 0.5 else round(r, 3)

    _udf = env.get("SCALP_USE_DEFENSE_FILTERS")
    if _udf not in (None, "", "None"):
        use_def = str(_udf).strip().lower() in ("1", "true", "y", "yes", "on")
    else:
        use_def = bool(_d.get("use_defense_filters", True))
    _um = env.get("SCALP_USE_MACD_CROSS")
    if _um not in (None, "", "None"):
        use_macd = str(_um).strip().lower() in ("1", "true", "y", "yes", "on")
    else:
        use_macd = bool(_d.get("use_macd_cross", False))

    _mdl = env.get("SCALP_MIN_DROP_PCT_FOR_LOSS_CUT")
    if _mdl not in (None, "", "None"):
        try:
            v = float(_mdl)
            min_drop_loss_ui = v * 100 if v < 1 else v
        except (ValueError, TypeError):
            min_drop_loss_ui = float(_d.get("min_drop_pct_for_loss_cut", 0.015)) * 100
    else:
        min_drop_loss_ui = float(_d.get("min_drop_pct_for_loss_cut", 0.015)) * 100

    sec = env.get("SCALP_COOLDOWN_SEC")
    if sec not in (None, "", "None"):
        cooldown_min = max(0, int(float(sec)) // 60)
    else:
        cooldown_min = int(_d.get("cooldown_min", 10))

    return {
        "rsi_oversold": int(float(env.get("SCALP_RSI_OVERSOLD") or _d.get("rsi_oversold", 25))),
        "rsi_overbought": int(float(env.get("SCALP_RSI_OVERBOUGHT") or _d.get("rsi_overbought", 75))),
        "sl_pct": pct_ui("SCALP_STOP_LOSS_PCT", _d.get("sl_pct", 0.015)),
        "tp_pct": pct_ui("SCALP_TAKE_PROFIT_PCT", _d.get("tp_pct", 0.015)),
        "tp_max_pct": pct_ui("SCALP_TP_MAX_PCT", _d.get("tp_max_pct", 0.02)),
        "drop_rate": pct_ui("SCALP_MIN_DROP_RATE", _d.get("drop_rate", 0.015)),
        "shoulder_min_high": shoulder_ui(_d.get("shoulder_min_high", 0.005)),
        "shoulder_cut_pct": shoulder_cut_ui(_d.get("shoulder_cut_pct", 0.003)),
        "trail_trigger": pct_ui("SCALP_ATR_UP_MULT", _d.get("trail_trigger", 0.007)),
        "trail_stop": pct_ui("SCALP_ATR_DOWN_MULT", _d.get("trail_stop", 0.004)),
        "cooldown_min": cooldown_min,
        "slot_money": float(env.get("SLOT_MONEY_DEFAULT") or _d.get("slot_money", 3_000_000)),
        "high_chase_thr": float(
            env.get("SCALP_HIGH_PRICE_CHASE_THRESHOLD")
            or env.get("HIGH_CHASE_THR")
            or _d.get("high_chase_thr", 0.96)
        ),
        "max_daily_chg": float(
            env.get("SCALP_MAX_DAILY_CHANGE_PCT")
            or env.get("MAX_DAILY_CHG")
            or _d.get("max_daily_chg", 20.0)
        ),
        "min_price": float(env.get("SCALP_MIN_PRICE") or _d.get("min_price", 1000.0)),
        "max_loss_krw": int(float(
            env.get("SCALP_MAX_LOSS_PER_TRADE_KRW")
            or env.get("MAX_LOSS_PER_TRADE_KRW")
            or _d.get("max_loss_krw", 200_000)
        )),
        "min_drop_pct_for_loss_cut": min_drop_loss_ui,
        "min_margin": float(env.get("SCALP_MIN_PROFIT_PCT") or _d.get("min_margin", 0.2)),
        "use_defense_filters": use_def,
        "use_macd_cross": use_macd,
    }


def _bo_golden_end_to_hm(s: str) -> int:
    """'10:30' 또는 HHMM → 1030 (실패 시 1030)."""
    try:
        raw = str(s or "").strip()
        if ":" in raw:
            hh, mm = raw.split(":", 1)
            return int(hh) * 100 + int(mm)
        if raw.isdigit():
            return int(raw[:4]) if len(raw) >= 4 else int(raw)
    except Exception:
        pass
    return 1030


def _bo_defaults_from_db() -> Dict[str, Any]:
    """돌파 백테·웹 폼 — config_breakout + env_config 병합."""
    fee = _get_fee_defaults()
    env = _strategy_env("BREAKOUT")

    def pick(keys: Tuple[str, ...], default: Any, cast=float):
        for k in keys:
            v = env.get(k)
            if v not in (None, "", "None"):
                try:
                    return cast(v)
                except (ValueError, TypeError):
                    continue
        return default

    sl_r = pick(("BREAKOUT_STOP_LOSS_PCT",), -0.02, float)
    tp_r = pick(("BREAKOUT_TAKE_PROFIT_PCT",), 0.05, float)
    tr_r = pick(("BREAKOUT_TRAIL_PCT",), 0.015, float)
    tra_r = pick(("BREAKOUT_TRAIL_ARM_PCT",), 0.0, float)
    smh_r = pick(("BREAKOUT_SHOULDER_MIN_HIGH_PCT",), 0.02, float)
    sc_r = pick(("BREAKOUT_SHOULDER_CUT_PCT",), 0.01, float)

    def pct_ui(ratio: float) -> float:
        av = abs(float(ratio))
        if av == 0:
            return 0.0
        return round(av * 100, 3) if av < 0.5 else round(av, 3)

    time_end_raw = env.get("BREAKOUT_TIME_END")
    if time_end_raw not in (None, "", "None"):
        try:
            time_end_hm = int(float(time_end_raw))
        except (ValueError, TypeError):
            time_end_hm = _bo_golden_end_to_hm(str(env.get("BREAKOUT_GOLDEN_END_HM", "10:30")))
    else:
        time_end_hm = _bo_golden_end_to_hm(str(env.get("BREAKOUT_GOLDEN_END_HM", "10:30")))

    cd_sec = pick(("BREAKOUT_COOLDOWN_SEC",), 0.0, float)
    if cd_sec and cd_sec > 0:
        cooldown_min = int(cd_sec / 60) if cd_sec > 120 else int(cd_sec)
    else:
        re_sec = pick(("REENTRY_COOLDOWN_SEC",), 1800.0, float)
        cooldown_min = int(re_sec / 60) if re_sec > 120 else int(re_sec)

    sl_pct_ui = pct_ui(sl_r)
    max_loss_raw = pick(
        ("BREAKOUT_MAX_LOSS_PER_TRADE_KRW", "MAX_LOSS_PER_TRADE_KRW"),
        200_000,
        lambda v: int(float(v)),
    )
    max_loss_krw = normalize_breakout_max_loss_krw(max_loss_raw)
    slot_cap = pick(
        ("BREAKOUT_SLOT_MONEY", "SLOT_MONEY_DEFAULT"),
        2_000_000,
        lambda v: int(float(v)),
    )
    slot_money = int(breakout_invest_amount_krw(max_loss_krw, sl_pct_ui, slot_cap))

    portfolio = bbc.resolve_breakout_portfolio_params(
        env, None, slot_money=float(slot_money),
    )
    max_stocks_v = int(portfolio["max_stocks"])
    total_budget_v = int(float(portfolio["total_budget_krw"]))

    eod_raw = str(env.get("BREAKOUT_EOD_HM") or "15:15").strip()
    if eod_raw in ("", "None"):
        eod_hm = "15:15"
    elif ":" in eod_raw:
        eod_hm = eod_raw
    elif len(eod_raw) == 4 and eod_raw.isdigit():
        eod_hm = f"{eod_raw[:2]}:{eod_raw[2:]}"
    else:
        eod_hm = eod_raw
    eod_enabled_raw = env.get("BREAKOUT_EOD_ENABLED")
    if eod_enabled_raw in (None, "", "None"):
        eod_enabled = True
    else:
        eod_enabled = str(eod_enabled_raw).strip().lower() in ("1", "true", "t", "y", "yes", "on")

    return {
        "lookback_min":   pick(("BREAKOUT_LOOKBACK_MIN",), 1, lambda v: int(float(v))),
        "vol_window":     pick(("BREAKOUT_VOL_WIN",), 1, lambda v: int(float(v))),
        "vol_mult":       pick(("BREAKOUT_VOL_MULT",), 0.0, float),
        "min_turnover_1m_pct": pick(("BREAKOUT_MIN_TURNOVER_1M_PCT",), 0.05, float),
        "prev_chg_min":   pick(("BREAKOUT_PREV_CHG_MIN",), 1.0, float),
        "prev_chg_max":   pick(("BREAKOUT_PREV_CHG_MAX",), 10.0, float),
        "sl_pct":         sl_pct_ui,
        "tp_pct":         pct_ui(tp_r),
        "trail_pct":      pct_ui(tr_r),
        "trail_arm_pct":  pct_ui(tra_r),
        "shoulder_min_high_pct": pct_ui(smh_r),
        "shoulder_cut_pct": pct_ui(sc_r),
        # ATR 동적 손절 (sl_mode='atr' 일 때만 활성, 기본 fixed=기존 고정%)
        "sl_mode": str(
            env.get("BREAKOUT_SL_MODE") or "fixed"
        ).strip().lower() or "fixed",
        "atr_period": pick(("BREAKOUT_ATR_PERIOD",), 14, lambda v: int(float(v))),
        "atr_sl_mult": pick(("BREAKOUT_ATR_SL_MULT",), 2.0, float),
        "atr_sl_min_pct": pick(("BREAKOUT_ATR_SL_MIN_PCT",), 0.8, float),
        "atr_sl_max_pct": pick(("BREAKOUT_ATR_SL_MAX_PCT",), 6.0, float),
        "max_hold_bars":  pick(("BREAKOUT_MAX_HOLD_BARS",), 0, lambda v: int(float(v))),
        "ratchet_tiers":  str(env.get("BREAKOUT_RATCHET_TIERS") or ""),
        "time_start_hm":  pick(("BREAKOUT_TIME_START",), 900, lambda v: int(float(v))),
        "time_end_hm":    time_end_hm,
        "eod_enabled":    eod_enabled,
        "eod_hm":         eod_hm,
        "max_daily":      pick(("BREAKOUT_MAX_DAILY",), 1, lambda v: int(float(v))),
        "cooldown_min":   cooldown_min,
        "max_daily_chg":  pick(("BREAKOUT_MAX_DAILY_CHG",), 15.0, float),
        "min_price":      pick(("BREAKOUT_MIN_PRICE", "MIN_STOCK_PRICE"), 1000.0, float),
        # 가짜돌파(휩쏘) 필터 — 0=OFF
        "confirm_margin_pct": pick(("BREAKOUT_CONFIRM_MARGIN_PCT",), 0.0, float),
        "body_min_pct":   pick(("BREAKOUT_BODY_MIN_PCT",), 0.0, float),
        "max_loss_krw":   max_loss_krw,
        "slot_money":     slot_money,
        "max_stocks":     max_stocks_v,
        "total_budget_krw": total_budget_v,
        "fee_rate_pct":   fee.get("fee_rate", 0.015),
        "sell_tax_pct":   fee.get("sell_tax", 0.18),
        "entry_mode":     str(
            env.get("BREAKOUT_ENTRY_MODE") or "intrabar"
        ).strip().lower(),
        "intrabar_slippage_pct": float(
            pick(("BREAKOUT_INTRABAR_SLIPPAGE_PCT",), 0.0, float)
        ),
        "use_ema_filter": (
            str(env.get("BREAKOUT_USE_EMA_FILTER")).strip().lower()
            in ("1", "true", "t", "y", "yes", "on")
            if env.get("BREAKOUT_USE_EMA_FILTER") not in (None, "", "None")
            else False
        ),
        "ema_fast_period": pick(("BREAKOUT_EMA_FAST_PERIOD",), 9, lambda v: int(float(v))),
        "ema_slow_period": pick(("BREAKOUT_EMA_SLOW_PERIOD",), 21, lambda v: int(float(v))),
        "ob_filter_enabled": _strategy_trigger_filter_enabled(
            env, prefix="BREAKOUT", kind="ORDERBOOK", global_key="ORDERBOOK_FILTER_ENABLED",
        ),
        "pg_filter_enabled": _strategy_trigger_filter_enabled(
            env, prefix="BREAKOUT", kind="PROGRAM", global_key="PROGRAM_FILTER_ENABLED",
        ),
        "max_spread_pct": pick(
            ("BREAKOUT_ORDERBOOK_MAX_SPREAD_PCT", "ORDERBOOK_MAX_SPREAD_PCT"), 0.45, float,
        ),
    }


def _breakout_optimal_from_search() -> Dict[str, Any]:
    """최신 search_breakout_*.json 1위 merged_params → 웹 폼 키."""
    try:
        from kis_trader.backtest.param_search_breakout import _latest_json
    except ImportError:
        return {}
    path = _latest_json("search_breakout_")
    if not path or not os.path.isfile(path):
        return {}
    try:
        with open(path, "r", encoding="utf-8") as f:
            data = json.load(f)
    except (OSError, json.JSONDecodeError):
        return {}
    top = data.get("top") or []
    if not top:
        return {}
    item = top[0]
    merged = dict(item.get("merged_params") or {})
    grid = item.get("params") or {}
    if isinstance(grid, dict):
        merged.update(grid)
    # CLI 포트폴리오·매수시작만 meta 반영 (time_end는 그리드 1위 우선 — session 1530 덮어쓰기 방지)
    for k in ("slot_money", "max_stocks", "total_budget_krw", "time_start_hm"):
        if data.get(k) is not None:
            merged[k] = data[k]
    if grid.get("time_end_hm") is not None:
        merged["time_end_hm"] = grid["time_end_hm"]
    out: Dict[str, Any] = {}
    key_map = (
        "lookback_min", "vol_window", "vol_mult", "prev_chg_min", "prev_chg_max",
        "sl_pct", "tp_pct", "trail_pct", "shoulder_min_high_pct", "shoulder_cut_pct",
        "sl_mode", "atr_period", "atr_sl_mult", "atr_sl_min_pct", "atr_sl_max_pct",
        "time_start_hm", "time_end_hm", "max_daily", "cooldown_min", "max_daily_chg",
        "min_price", "max_loss_krw", "slot_money", "max_stocks", "total_budget_krw",
    )
    for k in key_map:
        v = merged.get(k)
        if v is not None and v != "":
            out[k] = v
    return out


def _breakout_ui_defaults(*, prefer_search_json: bool = False) -> Dict[str, Any]:
    """돌파 탭 입력란용 — 기본은 config_breakout + env_config (봇·실매와 동일).

    ``prefer_search_json=True`` 일 때만 최신 search_breakout_*.json 1위로 덮어씀 (백테 탐색용).
    💾 봇에 설정저장 / ``/api/env/params`` 는 DB만 사용해야 저장값이 보인다.
    """
    base = _bo_defaults_from_db()
    if not prefer_search_json:
        return base
    opt = _breakout_optimal_from_search()
    for k, v in opt.items():
        if v is not None:
            base[k] = v
    return base


def _bo_ui_to_engine_params(ui: Dict[str, Any]) -> Dict[str, Any]:
    """웹 폼(%) → 엔진 (``breakout_ui_to_engine_params`` — param_search 와 동일)."""
    fee = _get_fee_defaults()
    merged = dict(ui)
    merged.setdefault("fee_rate_pct", fee.get("fee_rate", 0.015))
    merged.setdefault("sell_tax_pct", fee.get("sell_tax", 0.18))
    return breakout_ui_to_engine_params(merged)


def _rb_defaults_from_db() -> Dict[str, Any]:
    """박스권 돌파 백테·웹 폼 — config_range_break + env_config 병합."""
    fee = _get_fee_defaults()
    env = _strategy_env("RANGE_BREAK")

    def pick(keys: Tuple[str, ...], default: Any, cast=float):
        for k in keys:
            v = env.get(k)
            if v not in (None, "", "None"):
                try:
                    return cast(v)
                except (ValueError, TypeError):
                    continue
        return default

    def pct_ui(ratio: float) -> float:
        av = abs(float(ratio))
        if av == 0:
            return 0.0
        return round(av * 100, 3) if av < 0.5 else round(av, 3)

    sl_r = pick(("RANGE_BREAK_STOP_LOSS_PCT",), -0.03, float)
    tp_r = pick(("RANGE_BREAK_TAKE_PROFIT_PCT",), 0.10, float)
    tr_r = pick(("RANGE_BREAK_TRAIL_PCT",), 0.015, float)
    tra_r = pick(("RANGE_BREAK_TRAIL_ARM_PCT",), 0.015, float)
    smh_r = pick(("RANGE_BREAK_SHOULDER_MIN_HIGH_PCT",), 0.03, float)
    sc_r = pick(("RANGE_BREAK_SHOULDER_CUT_PCT",), 0.005, float)

    cd_sec = pick(("RANGE_BREAK_COOLDOWN_SEC",), 1800.0, float)
    cooldown_min = int(cd_sec / 60) if cd_sec > 120 else int(cd_sec)

    sl_pct_ui = pct_ui(sl_r)
    max_loss_raw = pick(
        ("RANGE_BREAK_MAX_LOSS_PER_TRADE_KRW", "MAX_LOSS_PER_TRADE_KRW"),
        200_000,
        lambda v: int(float(v)),
    )
    max_loss_krw = normalize_breakout_max_loss_krw(max_loss_raw)
    slot_cap = pick(
        ("RANGE_BREAK_SLOT_MONEY", "SLOT_MONEY_DEFAULT"),
        200_000,
        lambda v: int(float(v)),
    )
    slot_money = int(breakout_invest_amount_krw(max_loss_krw, sl_pct_ui, slot_cap))

    portfolio = rbc.resolve_range_break_portfolio_params(
        env, None, slot_money=float(slot_money),
    )

    use_hc = env.get("RANGE_BREAK_USE_HIGH_CHASE_FILTER")
    if use_hc not in (None, "", "None"):
        use_high_chase = str(use_hc).strip().lower() in ("1", "true", "t", "y", "yes", "on")
    else:
        use_high_chase = True

    return {
        "box_lookback_min": pick(("RANGE_BREAK_BOX_LOOKBACK_MIN",), 30, lambda v: int(float(v))),
        "box_max_width_pct": pick(("RANGE_BREAK_BOX_MAX_WIDTH_PCT",), 2.5, float),
        "box_min_width_pct": pick(("RANGE_BREAK_BOX_MIN_WIDTH_PCT",), 0.3, float),
        "setup_vol_max_mult": pick(("RANGE_BREAK_SETUP_VOL_MAX_MULT",), 0.8, float),
        "setup_bear_bars_min": pick(("RANGE_BREAK_SETUP_BEAR_BARS_MIN",), 1, lambda v: int(float(v))),
        "vol_mult": pick(("RANGE_BREAK_VOL_MULT",), 2.0, float),
        "vol_window": pick(("RANGE_BREAK_VOL_WIN",), 7, lambda v: int(float(v))),
        "vol_baseline_win": pick(("RANGE_BREAK_VOL_BASELINE_WIN",), 30, lambda v: int(float(v))),
        "break_margin_pct": pick(("RANGE_BREAK_BREAK_MARGIN_PCT",), 0.0, float),
        "body_min_pct": pick(("RANGE_BREAK_BODY_MIN_PCT",), 0.0, float),
        "sl_pct": sl_pct_ui,
        "tp_pct": pct_ui(tp_r),
        "trail_pct": pct_ui(tr_r),
        "trail_arm_pct": pct_ui(tra_r),
        "shoulder_min_high_pct": pct_ui(smh_r),
        "shoulder_cut_pct": pct_ui(sc_r),
        "max_hold_bars": pick(("RANGE_BREAK_MAX_HOLD_BARS",), 0, lambda v: int(float(v))),
        "time_start_hm": pick(("RANGE_BREAK_TIME_START",), 1030, lambda v: int(float(v))),
        "time_end_hm": pick(("RANGE_BREAK_TIME_END_HM",), 1520, lambda v: int(float(v))),
        "max_daily": pick(("RANGE_BREAK_MAX_DAILY",), 1, lambda v: int(float(v))),
        "cooldown_min": cooldown_min,
        "max_daily_chg": pick(("RANGE_BREAK_MAX_DAILY_CHG",), 25.0, float),
        "min_price": pick(("RANGE_BREAK_MIN_PRICE", "MIN_STOCK_PRICE"), 1000.0, float),
        "high_chase_thr": pick(("RANGE_BREAK_HIGH_CHASE_THR",), 0.96, float),
        "use_high_chase_filter": use_high_chase,
        "max_loss_krw": max_loss_krw,
        "slot_money": slot_money,
        "max_stocks": int(portfolio["max_stocks"]),
        "total_budget_krw": int(float(portfolio["total_budget_krw"])),
        "fee_rate_pct": fee.get("fee_rate", 0.015),
        "sell_tax_pct": fee.get("sell_tax", 0.18),
    }


def _rb_ui_to_engine_params(ui: Dict[str, Any]) -> Dict[str, Any]:
    fee = _get_fee_defaults()
    merged = dict(ui)
    merged.setdefault("fee_rate_pct", fee.get("fee_rate", 0.015))
    merged.setdefault("sell_tax_pct", fee.get("sell_tax", 0.18))
    return range_break_ui_to_engine_params(merged)


def _backtest_filter_toggle(raw: Any) -> Optional[bool]:
    """백테 폼 필터 토글 쿼리값 → True/False, 비었으면 None(=DB/실매값 사용).

    켜고/끄고 돌리는 비교는 이 1회 백테에만 적용된다. DB(실매)는 안 건드린다.
    """
    if raw is None or str(raw).strip() == "":
        return None
    return str(raw).strip().lower() in ("1", "true", "y", "yes", "on")


def _eod_params_from_request(
    req: Any,
    defaults: Dict[str, Any],
    *,
    default_enabled: bool = True,
    default_hm: str = "15:25",
) -> Dict[str, Any]:
    """웹 백테 쿼리 → 실매와 동일 ``eod_enabled`` / ``eod_hm`` (비우면 DB 기본값)."""
    raw_en = req.args.get("eod_enabled")
    if raw_en in (None, ""):
        eod_enabled = bool(defaults.get("eod_enabled", default_enabled))
    else:
        eod_enabled = str(raw_en).strip().lower() in ("1", "true", "t", "y", "yes", "on")
    raw_hm = req.args.get("eod_hm")
    if raw_hm not in (None, ""):
        eod_hm = str(raw_hm).strip() or default_hm
    else:
        eod_hm = str(defaults.get("eod_hm") or default_hm).strip() or default_hm
    return {"eod_enabled": eod_enabled, "eod_hm": eod_hm}


def _daily_trail_params_from_request(req: Any) -> Dict[str, Any]:
    """백테 탭 '당일 누적손익 트레일 익절' 입력 → 시뮬 파라미터(전용 daily_trail_* 키).

    drop<=0 이면 빈 dict 반환 → 게이트 OFF(기존 백테 동작 불변).
    arm_krw<=0 이면 실매 _trail_reached 가 비활성이라 트레일이 발동하지 않는다
    (작은 수익에 조기 종료 방지 — 실매와 동일 안전장치). UI 힌트로 안내.
    꼬리 개별포지션 'trail_arm_pct' 와 키가 겹치지 않도록 daily_trail_* 전용 키 사용.
    """
    mode = str(req.args.get("daily_profit_mode") or "trailing").strip().lower() or "trailing"
    # 다단 tier 우선 — 값이 있으면 단일 drop 무시. 'off'/빈값이면 단일 drop 경로.
    tiers = str(req.args.get("daily_trail_tiers") or "").strip()
    if tiers and tiers.lower() != "off":
        return {
            "_backtest_daily_profit_trail": True,
            "daily_profit_mode": mode,
            "daily_trail_tiers": tiers,
        }
    try:
        drop = float(req.args.get("daily_trail_drop_pct") or 0)
    except (TypeError, ValueError):
        drop = 0.0
    if drop <= 0:
        return {}
    try:
        arm_krw = float(req.args.get("daily_trail_arm_krw") or 0)
    except (TypeError, ValueError):
        arm_krw = 0.0
    return {
        "_backtest_daily_profit_trail": True,
        "daily_profit_mode": mode,
        "daily_trail_drop_pct": drop,
        "daily_trail_arm_krw": arm_krw,
    }


def _strategy_trigger_filter_enabled(
    env: Dict[str, Any],
    *,
    prefix: str,
    kind: str,
    global_key: str,
) -> bool:
    """전략별 TRIGGER 필터 ON/OFF — 전략키 우선, 없으면 공통키, 둘 다 없으면 True."""
    sk = f"{prefix}_{kind}_FILTER_ENABLED"
    raw = env.get(sk)
    if raw not in (None, "", "None"):
        return str(raw).strip().lower() in ("1", "true", "t", "y", "yes", "on")
    raw_g = env.get(global_key)
    if raw_g not in (None, "", "None"):
        return str(raw_g).strip().lower() in ("1", "true", "t", "y", "yes", "on")
    return True


@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))

    mode = (request.args.get("mode") or "reversal").strip().lower()
    if mode not in ("reversal", "momentum"):
        mode = "reversal"
    _mom_def: Optional[Dict[str, Any]] = (
        _momentum_ui_defaults_from_db(_def) if mode == "momentum" else None
    )

    _sl_req = request.args.get("sl_pct")
    if _sl_req not in (None, ""):
        sl_pct = float(_sl_req) / 100
    elif _mom_def is not None:
        sl_pct = float(_mom_def["sl_pct"]) / 100
    else:
        sl_pct = float(request.args.get("sl_pct", 1.5)) / 100
    _tp_req = request.args.get("tp_pct")
    if _tp_req not in (None, ""):
        tp_pct = float(_tp_req) / 100
    elif _mom_def is not None:
        tp_pct = float(_mom_def["tp_pct"]) / 100
    else:
        tp_pct = float(request.args.get("tp_pct", 1.5)) / 100
    # UI·DB에서 손절을 음수 퍼센트로 줄 때(예: -1.2) 엔진 sl_pct 가 음수로 들어가
    # stop 가격이 진입가 위로 뒤집히는 문제 방지 (scalping_engine 도 abs 처리함).
    sl_pct = abs(sl_pct)
    tp_pct = abs(tp_pct)

    drop_rate      = float(request.args.get("drop_rate", 1.5)) / 100
    _slot_req = request.args.get("slot_money")
    if _slot_req not in (None, ""):
        slot_money = float(_slot_req)
    elif _mom_def is not None:
        slot_money = float(_mom_def["slot_money"])
    else:
        slot_money = float(_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"]))      
    _smin_req      = request.args.get("shoulder_min_high")
    if _smin_req not in (None, ""):
        shoulder_min_high = float(_smin_req) / 100
    elif _mom_def is not None:
        shoulder_min_high = float(_mom_def["shoulder_min_high"]) / 100.0
    else:
        shoulder_min_high = float(_def.get("shoulder_min_high", 0.005))
    _scut_req      = request.args.get("shoulder_cut_pct")
    if _scut_req not in (None, ""):
        shoulder_cut_pct = float(_scut_req) / 100
    elif _mom_def is not None:
        shoulder_cut_pct = float(_mom_def["shoulder_cut_pct"]) / 100.0
    else:
        shoulder_cut_pct = float(_def.get("shoulder_cut_pct", 0.003))
    _tpmax_req = request.args.get("tp_max_pct")
    if _tpmax_req not in (None, ""):
        tp_max_pct = float(_tpmax_req) / 100
    elif _mom_def is not None:
        tp_max_pct = float(_mom_def["tp_max_pct"]) / 100.0
    else:
        tp_max_pct = float(_def.get("tp_max_pct", 0.02))
    min_hold_sec   = float(_def.get("min_hold_sec", 30.0))
    _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")
    _use_macd = request.args.get("use_macd_cross")
    if _use_macd in (None, ""):
        use_macd_cross = bool(_def.get("use_macd_cross", False))
    else:
        use_macd_cross = str(_use_macd).strip().lower() in ("1", "true", "y", "yes", "on")
    _force_eod_raw = request.args.get("force_eod_exit")
    if _force_eod_raw in (None, ""):
        _mom_eod_src = _mom_def if _mom_def is not None else _def
        eod_patch = _eod_params_from_request(request, _mom_eod_src, default_hm="15:25")
    else:
        # 레거시 force_eod_exit 쿼리 (하위호환)
        eod_patch = {
            "eod_enabled": str(_force_eod_raw).strip().lower() in ("1", "true", "y", "yes", "on"),
            "eod_hm": str(
                (_mom_def or _def).get("eod_hm") or "15:25"
            ).strip() or "15:25",
        }

    # ── 모드 분기: reversal(기존, V자 반전) vs momentum(추격형, SCALP_MODE=momentum 백테스트) ──
    # 실매매 봇이 ``check_buy_signal_momentum_live`` 를 사용 중이면 백테스트도
    # ``mode=momentum`` 으로 호출해야 동일한 규칙으로 비교할 수 있다.
    # (mode / _mom_def 는 상단에서 이미 확정)

    # 모멘텀 진입 전용 파라미터 (mode=reversal 일 때는 무시)
    if _mom_def is not None:
        mom_rsi_min = float(request.args.get("mom_rsi_min", _mom_def["mom_rsi_min"]))
        mom_rsi_max = float(request.args.get("mom_rsi_max", _mom_def["mom_rsi_max"]))
        mom_vol_mult = float(request.args.get("mom_vol_mult", _mom_def["mom_vol_mult"]))
        mom_vol_win = int(float(request.args.get("mom_vol_win", _mom_def["mom_vol_win"])))
        _mom_time_end = request.args.get("mom_time_end")
        mom_time_end_hm = int(_mom_time_end) if _mom_time_end not in (None, "") else int(
            _mom_def["mom_time_end_hm"],
        )
    else:
        mom_rsi_min = float(request.args.get("mom_rsi_min", 50.0))
        mom_rsi_max = float(request.args.get("mom_rsi_max", 80.0))
        mom_vol_mult = float(request.args.get("mom_vol_mult", 1.5))
        mom_vol_win = int(float(request.args.get("mom_vol_win", 5)))
        _mom_time_end = request.args.get("mom_time_end")
        mom_time_end_hm = int(_mom_time_end) if _mom_time_end not in (None, "") else 1430

    # mode=momentum 일 때만: 위에서 이미 _def 기반으로 채운 값들을 DB 모멘텀 기본으로 덮어씀
    if _mom_def is not None:
        if request.args.get("cooldown_min") in (None, ""):
            cooldown_min = float(_mom_def["cooldown_min"])
        if request.args.get("shoulder_min_high") in (None, ""):
            shoulder_min_high = float(_mom_def["shoulder_min_high"]) / 100.0
        if request.args.get("shoulder_cut_pct") in (None, ""):
            shoulder_cut_pct = float(_mom_def["shoulder_cut_pct"]) / 100.0
        if request.args.get("tp_max_pct") in (None, ""):
            tp_max_pct = float(_mom_def["tp_max_pct"]) / 100.0
        if request.args.get("time_start") in (None, ""):
            time_start_hm = int(_mom_def["mom_time_start_hm"])
        if request.args.get("max_daily") in (None, ""):
            max_daily = int(_mom_def["max_daily"])
        if request.args.get("use_defense_filters") in (None, ""):
            use_defense_filters = bool(_mom_def["use_defense_filters"])

    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")
        if _mom_def is not None:
            high_chase_thr = float(_high_chase) if _high_chase not in (None, "") else float(
                _mom_def["high_chase_thr"],
            )
            max_daily_chg = float(_max_daily_ch) if _max_daily_ch not in (None, "") else float(
                _mom_def["max_daily_chg"],
            )
            min_price = float(_min_pr) if _min_pr not in (None, "") else float(_mom_def["min_price"])
            max_loss_krw = int(float(_max_loss)) if _max_loss not in (None, "") else int(
                _mom_def["max_loss_krw"],
            )
            min_margin = (
                float(_min_marg) / 100
                if _min_marg not in (None, "")
                else float(_mom_def["min_margin"]) / 100
            )
        else:
            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,
            "tp_max_pct":    tp_max_pct,
            "drop_rate":     drop_rate,
            "slot_money":    slot_money,
            "fee_rate":      fee_rate,
            "sell_tax":      sell_tax,
            "cooldown_min":  cooldown_min,
            "shoulder_min_high": shoulder_min_high,
            "shoulder_cut_pct": shoulder_cut_pct,
            "min_hold_sec": min_hold_sec,
            "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,
            "use_macd_cross": use_macd_cross,
            **eod_patch,
            "macd_fast": int(_def.get("macd_fast", 12)),
            "macd_slow": int(_def.get("macd_slow", 26)),
            "macd_signal": int(_def.get("macd_signal", 5)),
            "stoch_k_period": int(_def.get("stoch_k_period", 5)),
            "stoch_d_period": int(_def.get("stoch_d_period", 3)),
            "stoch_slow": int(_def.get("stoch_slow", 3)),
            # scan_interval_min — 유니버스 해석 후 덮어씀 (이력=1분, 시뮬=5분)
            "scan_interval_min": 5,
            # 모멘텀 진입 전용 (mode=momentum 에서만 사용)
            "mom_rsi_min":      mom_rsi_min,
            "mom_rsi_max":      mom_rsi_max,
            "mom_vol_mult":     mom_vol_mult,
            "mom_vol_win":      mom_vol_win,
            "mom_time_end_hm":  mom_time_end_hm,
        }
        # 백테 전용 필터 토글 (폼 체크박스 → 이 1회 백테에만 적용. 비우면 DB=실매값 사용)
        _ob_tg = _backtest_filter_toggle(request.args.get("ob_filter"))
        if _ob_tg is not None:
            params["_orderbook_filter_enabled"] = _ob_tg
        _pg_tg = _backtest_filter_toggle(request.args.get("pg_filter"))
        if _pg_tg is not None:
            params["_program_filter_enabled"] = _pg_tg
        # 호가 스프레드 상한(%) — kiwoom_0d 본체 재계산 (6/25~ 유효, 그 외 log_backfill 폴백)
        _spread_req = request.args.get("max_spread_pct")
        if _spread_req not in (None, ""):
            params["_ob_max_spread_pct"] = float(_spread_req)
            params["backtest_use_kiwoom_body_snapshot"] = True
            params["_backtest_use_kiwoom_body"] = True
        if mode == "momentum":
            _mmax = request.args.get("mom_max_from_open_pct")
            _mmin = request.args.get("mom_min_from_open_pct")
            params["mom_max_from_open_pct"] = float(_mmax) if _mmax not in (None, "") else float(
                _mom_def.get("mom_max_from_open_pct", 999.0),
            )
            params["mom_min_from_open_pct"] = float(_mmin) if _mmin not in (None, "") else float(
                _mom_def.get("mom_min_from_open_pct", -999.0),
            )
            _mom_eng = me.get_momentum_defaults_from_db()
            _tr_req = request.args.get("trail_pct")
            params["trail_pct"] = (
                abs(float(_tr_req)) / 100.0
                if _tr_req not in (None, "")
                else float(_mom_eng.get("trail_pct") or 0.0)
            )
            _ta_req = request.args.get("trail_arm_pct")
            params["trail_arm_pct"] = (
                abs(float(_ta_req)) / 100.0
                if _ta_req not in (None, "")
                else float(_mom_eng.get("trail_arm_pct") or 0.0)
            )
            _mh_req = request.args.get("max_hold_bars")
            params["max_hold_bars"] = (
                int(float(_mh_req))
                if _mh_req not in (None, "")
                else int(_mom_eng.get("max_hold_bars") or 0)
            )
            _rat_req = request.args.get("ratchet_tiers")
            params["ratchet_tiers"] = (
                str(_rat_req).strip()
                if _rat_req is not None
                else str(_mom_eng.get("ratchet_tiers") or "")
            )
            _uhcf = request.args.get("use_high_chase_filter")
            params["use_high_chase_filter"] = (
                str(_uhcf).strip().lower() in ("1", "true", "y", "yes", "on")
                if _uhcf not in (None, "")
                else bool(_mom_eng.get("use_high_chase_filter", False))
            )
            _udrf = request.args.get("use_daily_range_filter")
            params["use_daily_range_filter"] = (
                str(_udrf).strip().lower() in ("1", "true", "y", "yes", "on")
                if _udrf not in (None, "")
                else bool(_mom_eng.get("use_daily_range_filter", False))
            )
            _uef = request.args.get("use_ema_filter")
            params["use_ema_filter"] = (
                str(_uef).strip().lower() in ("1", "true", "y", "yes", "on")
                if _uef not in (None, "")
                else bool(_mom_eng.get("use_ema_filter", True))
            )
            _urmf = request.args.get("use_rsi_max_filter")
            params["use_rsi_max_filter"] = (
                str(_urmf).strip().lower() in ("1", "true", "y", "yes", "on")
                if _urmf not in (None, "")
                else bool(_mom_eng.get("use_rsi_max_filter", False))
            )
            _pbo = request.args.get("pattern_breakout")
            params["pattern_breakout"] = (
                str(_pbo).strip().lower() in ("1", "true", "y", "yes", "on")
                if _pbo not in (None, "")
                else bool(_mom_eng.get("pattern_breakout", True))
            )
            _pbp = request.args.get("pattern_pullback")
            params["pattern_pullback"] = (
                str(_pbp).strip().lower() in ("1", "true", "y", "yes", "on")
                if _pbp not in (None, "")
                else bool(_mom_eng.get("pattern_pullback", True))
            )
            _cl = request.args.get("chase_lookback_min")
            params["chase_lookback_min"] = (
                int(float(_cl))
                if _cl not in (None, "")
                else int(_mom_eng.get("chase_lookback_min", 10))
            )
            _pl = request.args.get("pullback_lookback_min")
            params["pullback_lookback_min"] = (
                int(float(_pl))
                if _pl not in (None, "")
                else int(_mom_eng.get("pullback_lookback_min", 15))
            )
            _pmin = request.args.get("pullback_min_pct")
            params["pullback_min_pct"] = (
                float(_pmin)
                if _pmin not in (None, "")
                else float(_mom_eng.get("pullback_min_pct", 0.3))
            )
            _pmax = request.args.get("pullback_max_pct")
            params["pullback_max_pct"] = (
                float(_pmax)
                if _pmax not in (None, "")
                else float(_mom_eng.get("pullback_max_pct", 3.0))
            )
            _sv = request.args.get("setup_vol_max_mult")
            params["setup_vol_max_mult"] = (
                float(_sv)
                if _sv not in (None, "")
                else float(_mom_eng.get("setup_vol_max_mult", 0.8))
            )
            _sb = request.args.get("setup_bear_bars_min")
            params["setup_bear_bars_min"] = (
                int(float(_sb))
                if _sb not in (None, "")
                else int(_mom_eng.get("setup_bear_bars_min", 1))
            )
            _efp = request.args.get("ema_fast_period")
            params["ema_fast_period"] = (
                int(float(_efp))
                if _efp not in (None, "")
                else int(_mom_eng.get("ema_fast_period", 9))
            )
            _esp = request.args.get("ema_slow_period")
            params["ema_slow_period"] = (
                int(float(_esp))
                if _esp not in (None, "")
                else int(_mom_eng.get("ema_slow_period", 21))
            )
        # 유니버스 — 통일 쿼리 universe=history|sim|all (동일 테이블 target_candidates_history)
        _univ_default = "history"
        use_saved_history, universe_mode, sim_kind_univ = _parse_backtest_universe_arg(
            request,
            default=_univ_default,
            sim_kind=("momentum" if mode == "momentum" else "reversal"),
        )
        _hist_strategy_id = "MOMENTUM" if mode == "momentum" else "SCALP"
        universe_by_slot, universe_source, universe_history_slots, _scan_iv = (
            _resolve_backtest_universe(
                db,
                start_key,
                end_key,
                use_saved_history,
                codes_candles,
                sim_kind=sim_kind_univ,
                scan_interval_min=5,
                strategy_id=_hist_strategy_id,
            )
        )
        params["scan_interval_min"] = _scan_iv

        latest_env = db.get_latest_env()
        env_row = dict(latest_env["snapshot"]) if latest_env else {}
        strat_id = "MOMENTUM" if mode == "momentum" else "SCALP"
        fee_rate_v, sell_tax_v, slot_from_env = sbc.fee_and_slot_from_env(
            env_row, strategy=strat_id,
        )
        slot_money_v = float(slot_money or slot_from_env)
        _mx = request.args.get("max_stocks") or request.args.get("slots")
        max_stocks_req = (
            int(float(_mx)) if _mx not in (None, "") else None
        )
        _tb = request.args.get("total_budget_krw")
        total_budget_req = (
            float(_tb) if _tb not in (None, "") else None
        )
        portfolio = sbc.resolve_scalp_portfolio_params(
            env_row,
            None,
            strategy=strat_id,
            slot_money=slot_money_v,
            max_stocks=max_stocks_req,
            total_budget_krw=total_budget_req,
        )
        max_stocks_v = int(portfolio["max_stocks"])
        total_budget_v = float(portfolio["total_budget_krw"])
        slot_money_v = float(portfolio["slot_money"])

        bt_meta: Dict[str, Any] = {
            "db": db,
            "start_key": start_key,
            "end_key": end_key,
        }
        all_virtual_trades = sbc.run_scalping_backtest_web_aligned(
            codes_candles,
            params,
            universe_by_slot,
            slot_money=slot_money_v,
            fee_rate=fee_rate_v,
            sell_tax=sell_tax_v,
            max_stocks=max_stocks_v,
            total_budget_krw=total_budget_v,
            mode=mode,
            meta_out=bt_meta,
        )

        # 당일 누적손익 트레일 익절 시뮬 (백테 탭 입력 → drop>0 일 때만 ON).
        # trades 에 pnl·buy_time·sell_time 부착 완료 후 신규진입 차단. (모멘텀 포함)
        _trail_p = _daily_trail_params_from_request(request)
        if _trail_p:
            from kis_trader.backtest.backtest_portfolio_common import apply_daily_profit_halt_sim
            all_virtual_trades = apply_daily_profit_halt_sim(
                all_virtual_trades, _trail_p, budget_krw=float(total_budget_v or 0),
            )

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

        period_days = _backtest_period_days(start, end, fallback=1)
        stats = sbc.summarize_scalp_trades(
            all_virtual_trades,
            total_budget_krw=total_budget_v,
            period_days=period_days,
        )
        total = int(stats["total_trades"])
        total_pnl = int(stats["total_pnl"])
        wins_n = int(stats["wins"])
        losses_n = int(stats["losses"])
        avg_hold = float(stats["avg_hold_min"])
        pf = float(stats["pf"])
        bot_pct = float(stats["bot_pct"])
        daily_avg_pct = float(stats["daily_avg_pct"])

        equity = []
        cum = 0.0
        peak_cum = 0.0
        peak_cum_at = ""
        for t in sorted(all_virtual_trades, key=lambda x: x.get("sell_time", "")):
            cum += t.get("pnl", 0)
            if cum > peak_cum:
                peak_cum = cum
                peak_cum_at = str(t.get("sell_time") or "")
            st = str(t.get("sell_time", ""))
            day = st[:8]
            if len(day) == 8:
                day_fmt = f"{day[:4]}-{day[4:6]}-{day[6:]}"
            else:
                day_fmt = day
            equity.append({"date": day_fmt, "cum_pnl": round(cum), "pnl": t.get("pnl", 0)})

        peak, mdd, cum = 0.0, 0.0, 0.0
        for t in all_virtual_trades:
            cum += t.get("pnl", 0)
            if cum > peak:
                peak = cum
            dd = peak - cum
            if dd > mdd:
                mdd = dd

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

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

        # 가상거래에도 종목명 표시 (실거래와 동일)
        if mode == "momentum":
            _enrich_momentum_trades_debug(all_virtual_trades, total_budget_krw=total_budget_v)
        trades_out = _trades_recent_first(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,
                "tp_max_pct":    tp_max_pct * 100,
                "effective_tp_pct": se.resolve_effective_tp_pct(tp_pct, tp_max_pct) * 100,
                "drop_rate":     drop_rate * 100,
                "slot_money":    slot_money_v,
                "max_stocks":    max_stocks_v,
                "total_budget_krw": total_budget_v,
                "cooldown_min":  cooldown_min,
                "vol_mult":      vol_mult,
                "shoulder_min_high": shoulder_min_high * 100,
                "shoulder_cut_pct": shoulder_cut_pct * 100,
                "min_hold_sec": min_hold_sec,
                "time_window":   f"{time_start_hm:04d}-{time_end_hm:04d}",
                **eod_patch,
                "max_daily":     max_daily,
                "codes_analyzed": len(codes),
                "universe_source":       universe_source,
                "universe_history_slots": universe_history_slots,
                "universe":              universe_mode,
                "universe_timing": (
                    "strict" if universe_source == "history_strict"
                    else ("minute" if universe_source == "history" else None)
                ) if mode == "momentum" else None,
                "strategy_id":           _hist_strategy_id,
                # 모드 정보 (프론트 요약 배지/디버깅용)
                "mode":            mode,
                "mom_rsi_min":     mom_rsi_min,
                "mom_rsi_max":     mom_rsi_max,
                "mom_vol_mult":    mom_vol_mult,
                "mom_vol_win":     mom_vol_win,
                "mom_time_end":    mom_time_end_hm,
                "mom_max_from_open_pct": params.get("mom_max_from_open_pct") if mode == "momentum" else None,
                "mom_min_from_open_pct": params.get("mom_min_from_open_pct") if mode == "momentum" else None,
                "use_ema_filter": params.get("use_ema_filter") if mode == "momentum" else None,
                "use_rsi_max_filter": params.get("use_rsi_max_filter") if mode == "momentum" else None,
                "pattern_breakout": params.get("pattern_breakout") if mode == "momentum" else None,
                "pattern_pullback": params.get("pattern_pullback") if mode == "momentum" else None,
                "chase_lookback_min": params.get("chase_lookback_min") if mode == "momentum" else None,
                "pullback_lookback_min": params.get("pullback_lookback_min") if mode == "momentum" else None,
                "pullback_min_pct": params.get("pullback_min_pct") if mode == "momentum" else None,
                "pullback_max_pct": params.get("pullback_max_pct") if mode == "momentum" else None,
                "ema_fast_period": params.get("ema_fast_period") if mode == "momentum" else None,
                "ema_slow_period": params.get("ema_slow_period") if mode == "momentum" else None,
                "trail_pct": (params.get("trail_pct", 0) * 100) if mode == "momentum" else None,
                "trail_arm_pct": (params.get("trail_arm_pct", 0) * 100) if mode == "momentum" else None,
                "max_hold_bars": params.get("max_hold_bars") if mode == "momentum" else None,
                "ratchet_tiers": params.get("ratchet_tiers") if mode == "momentum" else None,
                "exit_priority": (
                    "ratchet/shoulder→trail→sl→time→loss_cap→tp_max→eod"
                    if mode == "momentum" else None
                ),
                "start": start,
                "end": end,
            },
            "summary": {
                "total_trades":  total,
                "win_trades":    wins_n,
                "loss_trades":   losses_n,
                "win_rate":      float(stats["win_rate"]),
                "total_pnl":     total_pnl,
                "avg_hold_min":  round(avg_hold, 1),
                "profit_factor": round(pf, 2),
                "max_drawdown":  round(mdd),
                "bot_pct":       bot_pct,
                "daily_avg_pct": daily_avg_pct,
                "backtest_days": period_days,
                "budget_warning": portfolio.get("budget_warning"),
                "peak_cum_pnl": round(peak_cum) if mode == "momentum" else None,
                "peak_cum_at": peak_cum_at if mode == "momentum" else None,
                "tick_backtest": bt_meta.get("tick_backtest") if mode == "momentum" else None,
                "skip_stats": bt_meta.get("skip_stats") if mode == "momentum" else None,
            },
            "equity":  equity,
            "daily":   daily_list,
            "reasons": reasons,
            "trades":  trades_out,
        })
    except Exception as e:
        logger.exception("scalping backtest failed mode=%s", mode)
        return jsonify({"error": str(e)}), 500
    finally:
        db.close()


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

try:
    from kis_trader.engine import tail_engine as te
    _TAIL_ENGINE_AVAILABLE = True
except ImportError:
    _TAIL_ENGINE_AVAILABLE = False


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


def _tail_frac_to_ui_pct(v: Any) -> Optional[float]:
    """엔진 비율(0.003) 또는 퍼센트(3.0) → 웹 입력 퍼센트."""
    if v is None or v == "":
        return None
    try:
        x = abs(float(v))
        if x == 0:
            return 0.0
        return round(x * 100, 3) if x < 0.5 else round(x, 3)
    except (ValueError, TypeError):
        return None


def _tail_ratio_to_ui_pct(v: Any, default_pct: float) -> float:
    """0~1 비율 또는 퍼센트 → 폼 표시 % (max_rec_3m, high_chase)."""
    if v is None or v == "":
        return default_pct
    try:
        x = float(v)
        if 0 < x <= 1:
            return round(x * 100, 2)
        if x > 1:
            return round(x, 2)
    except (ValueError, TypeError):
        pass
    return default_pct


def _tail_engine_dict_to_ui(
    d: Dict[str, Any],
    snap: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
    """tail_engine defaults 또는 파라서치 merged → 웹 입력란 값."""
    snap = snap or {}
    if not d:
        return {}

    md_loss = d.get("min_drop_pct_for_loss_cut", 0.015)
    try:
        md_loss_f = float(md_loss)
        md_loss_ui = md_loss_f * 100 if md_loss_f < 1 else md_loss_f
    except (ValueError, TypeError):
        md_loss_ui = 1.5

    slot_raw = snap.get("TAIL_SLOT_MONEY") or d.get("slot_money") or "3000000"
    _em = str(d.get("entry_mode") or "limit_atr").strip().lower()

    return {
        "entry_mode": _em,
        "limit_atr_mult": d.get("limit_atr_mult", 1.5),
        "limit_anchor": d.get("limit_anchor", "signal_low"),
        "limit_valid_bars": int(d.get("limit_valid_bars") or 1),
        "limit_fill_slip_pct": float(d.get("limit_fill_slip_pct") or 0.0),
        "drop": _tail_frac_to_ui_pct(d.get("min_drop_rate")),
        # 회복률은 0~1 비율(0.5=50%) — 낙폭%와 달리 _tail_ratio_to_ui_pct 사용
        "rec": _tail_ratio_to_ui_pct(d.get("min_recovery_ratio"), 45.0),
        "tail_ratio": d.get("tail_ratio_min"),
        "tail_pct_min": _tail_frac_to_ui_pct(d.get("tail_pct_min")),
        "sl_pct": _tail_frac_to_ui_pct(d.get("sl_pct")),
        "tp_pct": _tail_frac_to_ui_pct(d.get("tp_pct")),
        "smin": _tail_frac_to_ui_pct(d.get("shoulder_min_high")),
        "scut": _tail_frac_to_ui_pct(d.get("shoulder_cut_pct")),
        "cool": d.get("cooldown_min"),
        "rsi": d.get("rsi_threshold"),
        "rsi_period": int(d.get("rsi_period") or 14),
        "time_start": int(d.get("time_start_hm") or 930),
        "time_end": int(d.get("time_end_hm") or 1500),
        "max_daily": int(d.get("max_daily") or 3),
        "max_rec_3m": _tail_ratio_to_ui_pct(d.get("max_rec_3m"), 90.0),
        "high_chase": _tail_ratio_to_ui_pct(d.get("high_chase_thr"), 96.0),
        "min_price": d.get("min_price"),
        "max_daily_change": d.get("max_daily_change"),
        "ma20_max_above": d.get("ma20_max_above"),
        "stop_atr_mult": d.get("stop_atr_mult"),
        "target_atr_mult": d.get("target_atr_mult"),
        "atr_sl_min_pct": d.get("atr_sl_min_pct"),
        "atr_sl_max_pct": d.get("atr_sl_max_pct"),
        "atr_tp_min_pct": d.get("atr_tp_min_pct"),
        "atr_tp_max_pct": d.get("atr_tp_max_pct"),
        "max_loss_krw": d.get("max_loss_krw"),
        "min_drop_pct_for_loss_cut": round(md_loss_ui, 2),
        "slot_money": int(float(slot_raw)),
        "max_stocks": int(snap.get("TAIL_MAX_STOCKS") or d.get("max_stocks") or 3),
        "total_budget_krw": int(float(
            snap.get("TAIL_TOTAL_BUDGET_KRW") or d.get("total_budget_krw") or 0
        ) or int(float(slot_raw)) * int(snap.get("TAIL_MAX_STOCKS") or d.get("max_stocks") or 3)),
        "skip_hts_scan_dupes": d.get("skip_hts_scan_dupes", True),
        "use_intraday_drop": d.get("use_intraday_drop", False),
        "use_ma20_filter": d.get("use_ma20_filter", False),
        "use_rsi_filter": d.get("use_rsi_filter", True),
        "use_daily_range_filter": d.get("use_daily_range_filter", True),
        "use_high_chase_filter": d.get("use_high_chase_filter", True),
        "bar_chg_min_pct": d.get("bar_chg_min_pct", -10.0),
        "bar_chg_max_pct": d.get("bar_chg_max_pct", -1.5),
        "tail_vol_mult": d.get("tail_vol_mult", 0.0),
        "tail_vol_win": int(d.get("tail_vol_win") or 5),
        "ratchet_tiers": str(d.get("ratchet_tiers") or "").strip(),
        "max_hold_bars": int(d.get("max_hold_bars") or 0),
        "trail_pct": _tail_frac_to_ui_pct(d.get("trail_pct")) or 0.0,
        "trail_arm_pct": _tail_frac_to_ui_pct(d.get("trail_arm_pct")) or 0.0,
        "backtest_use_tick_db": d.get("backtest_use_tick_db", False),
        "backtest_tick_fallback_ohlc": d.get("backtest_tick_fallback_ohlc", True),
        "pattern_hammer": d.get("pattern_hammer", True),
        "pattern_pin": d.get("pattern_pin", False),
        "pattern_engulfing": d.get("pattern_engulfing", False),
        "pattern_piercing": d.get("pattern_piercing", False),
        "pattern_harami": d.get("pattern_harami", False),
        "pattern_doji": d.get("pattern_doji", False),
        "pattern_morning_star": d.get("pattern_morning_star", False),
        "ob_filter_enabled": _strategy_trigger_filter_enabled(
            snap or {}, prefix="TAIL", kind="ORDERBOOK", global_key="ORDERBOOK_FILTER_ENABLED",
        ),
        "pg_filter_enabled": _strategy_trigger_filter_enabled(
            snap or {}, prefix="TAIL", kind="PROGRAM", global_key="PROGRAM_FILTER_ENABLED",
        ),
        "max_spread_pct": float(
            snap.get("TAIL_ORDERBOOK_MAX_SPREAD_PCT")
            or snap.get("ORDERBOOK_MAX_SPREAD_PCT")
            or d.get("max_spread_pct")
            or 0.45
        ),
        # 당일 누적손익 다단 트레일(SHORT 일일익절) 현재값 + 사용자 저장 프리셋 목록(세미콜론 구분)
        "daily_trail_tiers": str(snap.get("SHORT_DAILY_PROFIT_TRAIL_TIERS") or "").strip(),
        "daily_profit_mode": str(snap.get("SHORT_DAILY_PROFIT_MODE") or "trailing").strip().lower() or "trailing",
        "ratchet_presets": str(snap.get("BT_RATCHET_PRESETS") or "").strip(),
        "daily_trail_presets": str(snap.get("BT_DAILY_TRAIL_PRESETS") or "").strip(),
        "eod_enabled": (
            str(snap.get("TAIL_EOD_ENABLED") or "1").strip().lower()
            in ("1", "true", "t", "y", "yes", "on")
            if snap.get("TAIL_EOD_ENABLED") not in (None, "", "None")
            else True
        ),
        "eod_hm": str(snap.get("TAIL_EOD_HM") or "15:25").strip() or "15:25",
    }


def _tail_ui_defaults_from_db(snap: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
    """
    꼬리잡기 웹 탭 초기값 — tail_engine.get_tail_defaults_from_db() 와 동일 (실매·파라서치·백테).
    슬롯금액만 config_short / env 병합 snap 에서 읽음.
    """
    d = _get_tail_defaults_for_backtest()
    return _tail_engine_dict_to_ui(d, snap)


def _daily_trail_save_patch(body: Dict[str, Any], prefix: str) -> Dict[str, str]:
    """
    당일 누적손익 다단 트레일 익절(레칫식) 저장 패치 — 전 전략 공통.
    body 의 daily_trail_tiers/daily_profit_mode → {prefix}_DAILY_PROFIT_* (config_{strategy}).

    tier 값이 있으면 trailing 모드 + 일일익절 활성화를 함께 저장해야 실매가 작동한다
    (없으면 _guard_active=False 라 무동작). 'off'/빈값이면 tier 만 비우고 모드는 안 건드림.
    prefix 예: 'SHORT'(꼬리)·'MOMENTUM'·'BREAKOUT'.
    """
    out: Dict[str, str] = {}
    if "daily_trail_tiers" not in body:
        return out
    tiers = str(body.get("daily_trail_tiers") or "").strip()
    if tiers and tiers.lower() != "off":
        out[f"{prefix}_DAILY_PROFIT_TRAIL_TIERS"] = tiers
        out[f"{prefix}_DAILY_PROFIT_MODE"] = (
            str(body.get("daily_profit_mode") or "trailing").strip().lower() or "trailing"
        )
        out[f"{prefix}_DAILY_PROFIT_TARGET_ENABLED"] = "true"
    else:
        out[f"{prefix}_DAILY_PROFIT_TRAIL_TIERS"] = ""
    return out


def _accumulate_preset(
    snap: Dict[str, Any], patch: Dict[str, str], key: str, value: Any, max_keep: int = 20
) -> None:
    """
    사용자가 직접 입력한 값(래칫·다단트레일)을 세미콜론 구분 프리셋 목록 키에 누적.
    값 내부에 콤마(0.5:0.3,1.0:0.25)가 있으므로 목록 구분자는 세미콜론을 쓴다.
    중복·빈값·'off'는 제외, 최근 max_keep 개만 유지. DB(env)에 영구 저장돼 드롭다운 복원에 쓰인다.
    """
    v = str(value or "").strip()
    if not v or v.lower() == "off":
        return
    existing = str(snap.get(key) or "").strip()
    items = [x.strip() for x in existing.split(";") if x.strip()] if existing else []
    if v in items:
        return
    items.append(v)
    if len(items) > max_keep:
        items = items[-max_keep:]
    patch[key] = ";".join(items)


def _tail_web_save_json_to_env_patch(body: Dict[str, Any]) -> Dict[str, str]:
    """
    꼬리잡기 탭 `saveTailConfig()` POST JSON → TAIL_* env 패치.
    insert_env_snapshot() 이 config_short 로 자동 분리 저장.
    """
    from kis_trader.engine.tail_env_keys import web_body_to_tail_env_patch
    patch = web_body_to_tail_env_patch(body)
    if "ob_filter" in body:
        patch["TAIL_ORDERBOOK_FILTER_ENABLED"] = _env_bool_10(body.get("ob_filter"))
    if "pg_filter" in body:
        patch["TAIL_PROGRAM_FILTER_ENABLED"] = _env_bool_10(body.get("pg_filter"))
    # 당일 누적손익 다단 트레일 — TAIL_* 가 아니라 SHORT 일일익절(daily_profit_halt) 키 (공통 헬퍼)
    patch.update(_daily_trail_save_patch(body, "SHORT"))
    return patch


@app.route("/api/backtest/tail/save_config", methods=["POST"])
def api_backtest_tail_save_config():
    """꼬리잡기 웹 폼 → insert_env_snapshot (config_short + env_config 분리 저장)."""
    if not _TAIL_ENGINE_AVAILABLE:
        return jsonify({"error": "tail_engine 미설치 또는 임포트 실패"}), 503
    body = request.get_json(force=True, silent=True) or {}
    try:
        patch = _tail_web_save_json_to_env_patch(body)
        if not patch:
            return jsonify({"error": "저장할 필드 없음(JSON 비어 있음)"}), 400
        db = _db()
        try:
            snap = db.get_merged_env_snapshot()
            # 사용자가 직접 입력한 래칫·다단트레일 값을 프리셋 목록(env)에 영구 누적
            _accumulate_preset(snap, patch, "BT_RATCHET_PRESETS", body.get("ratchet_tiers"))
            _accumulate_preset(snap, patch, "BT_DAILY_TRAIL_PRESETS", body.get("daily_trail_tiers"))
            for k, v in patch.items():
                snap[k] = v
            env_id = db.insert_env_snapshot(snap)
            if env_id is None:
                return jsonify({"error": "env 저장 실패(insert_env_snapshot)"}), 500
            from config_schema import classify_config_key
            saved_by_table: Dict[str, List[str]] = {}
            for k in patch:
                tbl = classify_config_key(k)
                saved_by_table.setdefault(tbl, []).append(k)
            return jsonify({
                "ok": True,
                "env_id": env_id,
                "saved_keys": list(patch.keys()),
                "saved_by_table": saved_by_table,
            })
        finally:
            db.close()
    except Exception as e:
        logger.error("꼬리잡기 설정저장 오류: %s", e)
        return jsonify({"error": str(e)}), 500


def _load_tail_search_json(path: Optional[str] = None) -> Tuple[Optional[str], Optional[Dict[str, Any]]]:
    """최신 search_tail_*.json 또는 지정 경로 로드."""
    if path and os.path.isfile(path):
        try:
            with open(path, "r", encoding="utf-8") as f:
                return path, json.load(f)
        except (OSError, json.JSONDecodeError):
            return path, None
    try:
        from kis_trader.backtest.tail_param_search import _latest_tail_json_path
        p = _latest_tail_json_path()
    except ImportError:
        p = None
    if not p or not os.path.isfile(p):
        return None, None
    try:
        with open(p, "r", encoding="utf-8") as f:
            return p, json.load(f)
    except (OSError, json.JSONDecodeError):
        return p, None


@app.route("/api/backtest/tail/search_results", methods=["GET"])
def api_backtest_tail_search_results():
    """최신 tail_param_search JSON 상위 N — 웹 폼 프리필·Apply UX."""
    top_n = max(1, min(100, int(request.args.get("top", 30))))
    json_path = (request.args.get("json") or "").strip() or None
    path, data = _load_tail_search_json(json_path)
    if not data:
        return jsonify({"error": "search_tail_*.json 없음 또는 파싱 실패", "path": path}), 404
    results = data.get("results") or []
    rows = []
    for idx, item in enumerate(results[:top_n]):
        merged = None
        try:
            from kis_trader.backtest.backtest_portfolio_common import merge_param_search_apply_source
            merged = merge_param_search_apply_source(item, data)
        except Exception:
            merged = dict(item.get("apply_cfg") or {})
        rows.append({
            "rank": idx + 1,
            "params": item.get("params") or {},
            "apply_cfg": item.get("apply_cfg") or {},
            "merged": merged,
            "total_trades": item.get("total_trades"),
            "win_rate": item.get("win_rate"),
            "total_pnl": item.get("total_pnl"),
            "pf": item.get("pf"),
            "avg_hold_min": item.get("avg_hold_min"),
            "sell_reasons": item.get("sell_reasons") or {},
        })
    meta = {
        "path": path,
        "mode": data.get("mode"),
        "start": data.get("start"),
        "end": data.get("end"),
        "timeframe": data.get("timeframe"),
        "grid_keys": data.get("grid_keys") or [],
        "grid_axis_hints": data.get("grid_axis_hints") or {},
        "tested_combos": data.get("tested_combos"),
        "cartesian_product": data.get("cartesian_product"),
        "slot_money": data.get("slot_money"),
        "max_stocks": data.get("max_stocks"),
        "total_budget_krw": data.get("total_budget_krw"),
    }
    return jsonify({"ok": True, "meta": meta, "top": rows})


@app.route("/api/backtest/tail/apply_search", methods=["POST"])
def api_backtest_tail_apply_search():
    """파라서치 N위 → 웹 폼 프리필 + 선택 시 DB 저장 (config_short TAIL_*)."""
    if not _TAIL_ENGINE_AVAILABLE:
        return jsonify({"error": "tail_engine 미설치 또는 임포트 실패"}), 503
    body = request.get_json(force=True, silent=True) or {}
    rank = max(1, int(body.get("rank") or 1))
    save_db = body.get("save_db", True)
    if isinstance(save_db, str):
        save_db = save_db.strip().lower() in ("1", "true", "yes", "on")
    json_path = (body.get("json") or "").strip() or None
    path, data = _load_tail_search_json(json_path)
    if not data:
        return jsonify({"error": "search_tail_*.json 없음", "path": path}), 404
    results = data.get("results") or []
    if rank > len(results):
        return jsonify({"error": f"rank 범위 초과 (1~{len(results)})"}), 400
    item = results[rank - 1]
    pnl = int(item.get("total_pnl") or 0)
    if pnl <= 0 and not body.get("allow_non_positive_pnl"):
        return jsonify({
            "error": f"total_pnl={pnl} ≤ 0 — DB 미적용. force 시 allow_non_positive_pnl=true",
            "rank": rank,
        }), 400
    try:
        from kis_trader.backtest.backtest_portfolio_common import merge_param_search_apply_source
        merged = merge_param_search_apply_source(item, data)
    except Exception as e:
        return jsonify({"error": f"merge 실패: {e}"}), 500
    ui = _tail_engine_dict_to_ui(merged)
    env_id = None
    if save_db:
        try:
            from kis_trader.backtest import tail_param_search as tps
            tps.apply_params_to_db(merged)
            db = _db()
            try:
                latest = db.get_latest_env()
                env_id = (latest or {}).get("id")
            finally:
                db.close()
        except Exception as e:
            logger.error("꼬리 파라서치 DB 적용 오류: %s", e)
            return jsonify({"error": str(e), "ui": ui}), 500
    return jsonify({
        "ok": True,
        "rank": rank,
        "path": path,
        "env_id": env_id,
        "saved": bool(save_db),
        "ui": ui,
        "merged": merged,
        "metrics": {
            "total_trades": item.get("total_trades"),
            "win_rate": item.get("win_rate"),
            "total_pnl": item.get("total_pnl"),
            "pf": item.get("pf"),
            "sell_reasons": item.get("sell_reasons") or {},
        },
    })


# ────────────────────────────────────────────────────────────────────────────
# API: 더블 볼린저 백테스트 (dbband_engine)
# ────────────────────────────────────────────────────────────────────────────

try:
    from kis_trader.engine import dbband_engine as bbe
    from kis_trader.backtest import dbband_backtest_common as dbbc
    _DBBAND_ENGINE_AVAILABLE = True
except ImportError:
    dbbc = None  # type: ignore
    _DBBAND_ENGINE_AVAILABLE = False


def _get_dbband_defaults_for_backtest():
    if not _DBBAND_ENGINE_AVAILABLE:
        return {}
    return bbe.get_dbband_defaults_from_db()


def _dbband_engine_dict_to_ui(d: Dict[str, Any], snap: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
    snap = snap or {}
    if not d:
        return {}
    slot_raw = snap.get("DBBAND_SLOT_MONEY") or d.get("slot_money") or "3000000"
    return {
        "bb_period": int(d.get("bb_period") or 20),
        "bb_inner_std": float(d.get("bb_inner_std") or 2.0),
        "bb_outer_std": float(d.get("bb_outer_std") or 3.0),
        "trend_ma_period": int(d.get("trend_ma_period") or 200),
        "use_trend_filter": d.get("use_trend_filter", True),
        "side_mode": str(d.get("side_mode") or "long_only"),
        "entry_valid_bars": int(d.get("entry_valid_bars") or 3),
        "entry_mode": str(d.get("entry_mode") or "break_high"),
        "stop_mode": str(d.get("stop_mode") or "signal_low"),
        "stop_buffer_pct": float(d.get("stop_buffer_pct") or 0.0) * 100,
        "sl_pct": float(d.get("sl_pct") or 0.02) * 100,
        "tp_mode": str(d.get("tp_mode") or "opposite_band"),
        "tp_pct": float(d.get("tp_pct") or 0.03) * 100,
        "rr_ratio": float(d.get("rr_ratio") or 2.0),
        "shoulder_min_high": float(d.get("shoulder_min_high") or 0.003) * 100,
        "shoulder_cut_pct": float(d.get("shoulder_cut_pct") or 0.002) * 100,
        "trail_pct": float(d.get("trail_pct") or 0.0) * 100,
        "trail_arm_pct": float(d.get("trail_arm_pct") or 0.0) * 100,
        "cooldown_min": float(d.get("cooldown_min") or 15.0),
        "time_start": int(d.get("time_start_hm") or 930),
        "time_end": int(d.get("time_end_hm") or 1500),
        "max_daily": int(d.get("max_daily") or 3),
        "min_price": float(d.get("min_price") or 1000.0),
        "slot_money": int(float(slot_raw)),
        "max_stocks": int(snap.get("DBBAND_MAX_STOCKS") or d.get("max_stocks") or 3),
        "total_budget_krw": int(float(
            snap.get("DBBAND_TOTAL_BUDGET_KRW") or d.get("total_budget_krw") or 0
        ) or int(float(slot_raw)) * int(snap.get("DBBAND_MAX_STOCKS") or d.get("max_stocks") or 3)),
        "max_hold_bars": int(d.get("max_hold_bars") or 0),
        "timeframe": int(d.get("timeframe") or 15),
        "exit_mode": str(d.get("exit_mode") or "classic"),
        "force_eod_exit": d.get("force_eod_exit", False),
    }


def _dbband_ui_defaults_from_db(snap: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
    d = _get_dbband_defaults_for_backtest()
    return _dbband_engine_dict_to_ui(d, snap)


@app.route("/api/backtest/dbband/save_config", methods=["POST"])
def api_backtest_dbband_save_config():
    if not _DBBAND_ENGINE_AVAILABLE:
        return jsonify({"error": "dbband_engine 미설치 또는 임포트 실패"}), 503
    body = request.get_json(force=True, silent=True) or {}
    try:
        from kis_trader.engine.dbband_env_keys import web_body_to_dbband_env_patch
        patch = web_body_to_dbband_env_patch(body)
        if not patch:
            return jsonify({"error": "저장할 필드 없음"}), 400
        db = _db()
        try:
            snap = db.get_merged_env_snapshot()
            for k, v in patch.items():
                snap[k] = v
            env_id = db.insert_env_snapshot(snap)
            if env_id is None:
                return jsonify({"error": "env 저장 실패"}), 500
            from config_schema import classify_config_key
            saved_by_table: Dict[str, List[str]] = {}
            for k in patch:
                tbl = classify_config_key(k)
                saved_by_table.setdefault(tbl, []).append(k)
            return jsonify({
                "ok": True,
                "env_id": env_id,
                "saved_keys": list(patch.keys()),
                "saved_by_table": saved_by_table,
            })
        finally:
            db.close()
    except Exception as e:
        logger.error("더블BB 설정저장 오류: %s", e)
        return jsonify({"error": str(e)}), 500


@app.route("/api/backtest/dbband", methods=["GET"])
def api_backtest_dbband_legacy():
    """레거시 — 종목별 ``/api/dbband/backtest?code=`` 사용."""
    return api_dbband_backtest()


@app.route("/api/backtest/momentum", methods=["GET"])
def api_backtest_momentum():
    """모멘텀 전용 백테스트 — momentum_engine + momentum_backtest_common (SCALP reversal 분리)."""
    args = request.args.to_dict(flat=True)
    args["mode"] = "momentum"
    with app.test_request_context(
        path="/api/backtest/scalping",
        query_string=args,
        method="GET",
    ):
        return api_backtest_scalping()


@app.route("/api/backtest/momentum/save_config", methods=["POST"])
def api_backtest_momentum_save_config():
    """모멘텀 탭 폼 → config_momentum + env_config INSERT."""
    body = request.get_json(force=True, silent=True) or {}
    try:
        patch = _momentum_tab_save_patch(body)
        if not patch:
            return jsonify({"error": "저장할 필드 없음"}), 400
        db = _db()
        try:
            latest = db.get_latest_env()
            snap = dict(latest["snapshot"]) if latest else {}
            # 사용자가 직접 입력한 다단 트레일 값을 프리셋 목록(env)에 영구 누적 (꼬리와 공유)
            _accumulate_preset(snap, patch, "BT_DAILY_TRAIL_PRESETS", body.get("daily_trail_tiers"))
            for k, v in patch.items():
                snap[k] = v
            env_id = db.insert_env_snapshot(snap)
            if env_id is None:
                return jsonify({"error": "env 저장 실패(insert_env_snapshot)"}), 500
            from config_schema import classify_config_key
            saved_by_table: Dict[str, List[str]] = {}
            for k in patch:
                tbl = classify_config_key(k)
                saved_by_table.setdefault(tbl, []).append(k)
            return jsonify({
                "ok": True,
                "env_id": env_id,
                "saved_keys": list(patch.keys()),
                "saved_by_table": saved_by_table,
            })
        finally:
            db.close()
    except Exception as e:
        logger.error("모멘텀 설정저장 오류: %s", e)
        return jsonify({"error": str(e)}), 500


@app.route("/api/backtest/momentum/search_results", methods=["GET"])
def api_backtest_momentum_search_results():
    """최신 param_search_momentum JSON 상위 N — 웹 폼 프리필·Apply UX."""
    top_n = max(1, min(100, int(request.args.get("top", 30))))
    json_path = (request.args.get("json") or "").strip() or None
    path, data = _load_momentum_search_json(json_path)
    if not data:
        return jsonify({"error": "search_momentum_*.json 없음 또는 파싱 실패", "path": path}), 404
    results = data.get("top") or data.get("results") or []
    rows = []
    for idx, item in enumerate(results[:top_n]):
        merged = None
        try:
            from kis_trader.backtest.backtest_portfolio_common import merge_param_search_apply_source
            merged = merge_param_search_apply_source(item, data)
        except Exception:
            merged = dict(item.get("merged_params") or item.get("params") or {})
        rows.append({
            "rank": item.get("rank") or (idx + 1),
            "params": item.get("params") or {},
            "merged_params": item.get("merged_params") or {},
            "merged": merged,
            "total_trades": item.get("total_trades"),
            "win_rate": item.get("win_rate"),
            "total_pnl": item.get("total_pnl"),
            "pf": item.get("pf"),
            "avg_hold_min": item.get("avg_hold") or item.get("avg_hold_min"),
            "sell_reasons": item.get("sell_reasons") or {},
        })
    meta = {
        "path": path,
        "strategy": data.get("strategy") or "MOMENTUM",
        "mode": data.get("mode"),
        "start": data.get("start"),
        "end": data.get("end"),
        "grid_keys": data.get("grid_keys") or [],
        "grid_axis_hints": data.get("grid_axis_hints") or {},
        "tested_combos": data.get("tested_combos"),
        "cartesian_product": data.get("cartesian_product"),
        "slot_money": data.get("slot_money"),
        "max_stocks": data.get("max_stocks"),
        "total_budget_krw": data.get("total_budget_krw"),
    }
    return jsonify({"ok": True, "meta": meta, "top": rows})


@app.route("/api/backtest/momentum/apply_search", methods=["POST"])
def api_backtest_momentum_apply_search():
    """파라서치 N위 → 웹 폼 프리필 + 선택 시 DB 저장 (MOMENTUM_*)."""
    body = request.get_json(force=True, silent=True) or {}
    rank = max(1, int(body.get("rank") or 1))
    save_db = body.get("save_db", True)
    if isinstance(save_db, str):
        save_db = save_db.strip().lower() in ("1", "true", "yes", "on")
    json_path = (body.get("json") or "").strip() or None
    path, data = _load_momentum_search_json(json_path)
    if not data:
        return jsonify({"error": "search_momentum_*.json 없음", "path": path}), 404
    results = data.get("top") or data.get("results") or []
    if rank > len(results):
        return jsonify({"error": f"rank 범위 초과 (1~{len(results)})"}), 400
    item = results[rank - 1]
    pnl = int(item.get("total_pnl") or 0)
    if pnl <= 0 and not body.get("allow_non_positive_pnl"):
        return jsonify({
            "error": f"total_pnl={pnl} ≤ 0 — DB 미적용. force 시 allow_non_positive_pnl=true",
            "rank": rank,
        }), 400
    try:
        from kis_trader.backtest.backtest_portfolio_common import merge_param_search_apply_source
        merged = merge_param_search_apply_source(item, data)
    except Exception as e:
        return jsonify({"error": f"merge 실패: {e}"}), 500
    ui = _momentum_engine_dict_to_ui(merged)
    env_id = None
    if save_db:
        try:
            from kis_trader.backtest.param_search_momentum import apply_params_to_db
            env_id = apply_params_to_db(merged)
            if env_id is None:
                return jsonify({"error": "DB 적용 실패", "ui": ui}), 500
        except Exception as e:
            logger.error("모멘텀 파라서치 DB 적용 오류: %s", e)
            return jsonify({"error": str(e), "ui": ui}), 500
    return jsonify({
        "ok": True,
        "rank": rank,
        "path": path,
        "env_id": env_id,
        "saved": bool(save_db),
        "ui": ui,
        "merged": merged,
        "metrics": {
            "total_trades": item.get("total_trades"),
            "win_rate": item.get("win_rate"),
            "total_pnl": item.get("total_pnl"),
            "pf": item.get("pf"),
            "avg_hold_min": item.get("avg_hold") or item.get("avg_hold_min"),
            "sell_reasons": item.get("sell_reasons") or {},
        },
    })


def _env_bool_10(v: Any) -> str:
    """env_config 불리언 컬럼용 문자열 (scalping_engine._to_bool 과 호환)."""
    if isinstance(v, bool):
        return "1" if v else "0"
    s = str(v).strip().lower()
    return "1" if s in ("1", "true", "t", "y", "yes", "on") else "0"


def _tail_bool_arg(request, key: str, def_val: Any) -> bool:
    """꼬리 백테 쿼리 불리언 → bool (미전달 시 DB 기본값). 파라서치·실매와 동일."""
    raw = request.args.get(key)
    if raw is None or raw == "":
        if isinstance(def_val, bool):
            return def_val
        s = str(def_val).strip().lower()
        return s in ("1", "true", "t", "y", "yes", "on")
    s = str(raw).strip().lower()
    return s in ("1", "true", "t", "y", "yes", "on")


def _momentum_tab_save_patch(body: Dict[str, Any]) -> Dict[str, str]:
    """모멘텀 백테 탭 → env_config (MomentumStrategy.reload_config · coarse 탐색과 동일 키 계열)."""
    if not isinstance(body, dict):
        return {}
    patch: Dict[str, str] = {}

    def gv(key: str) -> Any:
        v = body.get(key)
        if v is None or v == "":
            return None
        return v

    x = gv("mom_rsi_min")
    if x is not None:
        patch["MOMENTUM_RSI_MIN"] = str(float(x))
    x = gv("mom_rsi_max")
    if x is not None:
        patch["MOMENTUM_RSI_MAX"] = str(float(x))
    x = gv("mom_vol_mult")
    if x is not None:
        patch["MOMENTUM_VOL_MULT"] = str(float(x))
    x = gv("mom_vol_win")
    if x is not None:
        patch["MOMENTUM_VOL_WIN"] = str(int(float(x)))
    x = gv("mom_time_end")
    if x is not None:
        patch["MOMENTUM_TIME_END_HM"] = str(int(float(x)))
    x = gv("time_start")
    if x is not None:
        patch["MOMENTUM_TIME_START"] = str(int(float(x)))

    x = gv("sl_pct")
    if x is not None:
        patch["MOMENTUM_STOP_LOSS_PCT"] = str(abs(float(x)) / 100.0)
        patch["SCALP_STOP_LOSS_PCT"] = str(abs(float(x)) / 100.0)
    x = gv("tp_pct")
    if x is not None:
        patch["MOMENTUM_TAKE_PROFIT_PCT"] = str(abs(float(x)) / 100.0)
        patch["SCALP_TAKE_PROFIT_PCT"] = str(abs(float(x)) / 100.0)
    x = gv("tp_max_pct")
    if x is not None:
        sr = str(abs(float(x)) / 100.0)
        patch["MOMENTUM_TP_MAX_PCT"] = sr
        patch["SCALP_TP_MAX_PCT"] = sr

    x = gv("shoulder_min_high")
    if x is not None:
        sr = str(abs(float(x)) / 100.0)
        patch["MOMENTUM_SHOULDER_MIN_HIGH_PCT"] = sr
        patch["SCALP_SHOULDER_MIN_HIGH_PCT"] = sr
    x = gv("shoulder_cut_pct")
    if x is not None:
        sr = str(abs(float(x)) / 100.0)
        patch["MOMENTUM_SHOULDER_CUT_PCT"] = sr
        patch["SCALP_SHOULDER_CUT_PCT"] = sr

    x = gv("trail_trigger")
    if x is not None:
        patch["SCALP_ATR_UP_MULT"] = str(abs(float(x)) / 100.0)
    x = gv("trail_stop")
    if x is not None:
        patch["SCALP_ATR_DOWN_MULT"] = str(abs(float(x)) / 100.0)

    x = gv("cooldown_min")
    if x is not None:
        cd = str(int(float(x) * 60))
        patch["MOMENTUM_COOLDOWN_SEC"] = cd
        patch["SCALP_COOLDOWN_SEC"] = cd

    x = gv("max_daily")
    if x is not None:
        md = str(int(float(x)))
        patch["MOMENTUM_MAX_DAILY"] = md
        patch["SCALP_MAX_DAILY"] = md

    x = gv("slots")
    if x is not None:
        patch["MOMENTUM_MAX_STOCKS"] = str(int(float(x)))

    x = gv("slot_money")
    if x is not None:
        sms = str(int(float(x)))
        patch["MOMENTUM_SLOT_MONEY"] = sms
        patch["MOMENTUM_MAX_BUY_AMOUNT"] = sms

    x = gv("total_budget_krw")
    if x is not None:
        patch["MOMENTUM_TOTAL_BUDGET_KRW"] = str(int(float(x)))

    x = gv("high_chase_thr")
    if x is not None:
        hx = float(x)
        ratio = hx if 0 < hx <= 1 else hx / 100.0
        sr = str(ratio)
        patch["HIGH_CHASE_THR"] = sr
        patch["SCALP_HIGH_PRICE_CHASE_THRESHOLD"] = sr
        patch["HIGH_PRICE_CHASE_THRESHOLD"] = sr

    x = gv("max_daily_chg")
    if x is not None:
        vchg = str(float(x))
        patch["MAX_DAILY_CHG"] = vchg
        patch["SCALP_MAX_DAILY_CHANGE_PCT"] = vchg
        patch["MAX_DAILY_CHANGE_PCT"] = vchg

    x = gv("min_price")
    if x is not None:
        mp = str(float(x))
        patch["MOMENTUM_MIN_PRICE"] = mp
        patch["SCALP_MIN_PRICE"] = mp

    x = gv("max_loss_krw")
    if x is not None:
        ml = str(int(float(x)))
        patch["MOMENTUM_MAX_LOSS_PER_TRADE_KRW"] = ml
        patch["SCALP_MAX_LOSS_PER_TRADE_KRW"] = ml
        patch["MAX_LOSS_PER_TRADE_KRW"] = ml

    x = gv("min_margin")
    if x is not None:
        patch["MOMENTUM_MIN_PROFIT_PCT"] = str(float(x))
        patch["SCALP_MIN_PROFIT_PCT"] = str(float(x))

    x = gv("mom_max_from_open_pct")
    if x is not None:
        patch["MOMENTUM_MAX_FROM_OPEN_PCT"] = str(float(x))
    x = gv("mom_min_from_open_pct")
    if x is not None:
        patch["MOMENTUM_MIN_FROM_OPEN_PCT"] = str(float(x))

    if "use_defense_filters" in body:
        patch["MOMENTUM_USE_DEFENSE_FILTERS"] = _env_bool_10(body.get("use_defense_filters"))
        patch["SCALP_USE_DEFENSE_FILTERS"] = _env_bool_10(body.get("use_defense_filters"))

    if "ob_filter" in body:
        patch["MOMENTUM_ORDERBOOK_FILTER_ENABLED"] = _env_bool_10(body.get("ob_filter"))
    if "pg_filter" in body:
        patch["MOMENTUM_PROGRAM_FILTER_ENABLED"] = _env_bool_10(body.get("pg_filter"))

    if "eod_enabled" in body:
        patch["MOMENTUM_EOD_ENABLED"] = _env_bool_10(body.get("eod_enabled"))
    x = gv("eod_hm")
    if x is not None:
        eod_s = str(x).strip()
        if eod_s and ":" not in eod_s and len(eod_s) == 4 and eod_s.isdigit():
            eod_s = f"{eod_s[:2]}:{eod_s[2:]}"
        patch["MOMENTUM_EOD_HM"] = eod_s

    x = gv("trail_pct")
    if x is not None:
        patch["MOMENTUM_TRAIL_PCT"] = str(abs(float(x)) / 100.0)
    x = gv("trail_arm_pct")
    if x is not None:
        patch["MOMENTUM_TRAIL_ARM_PCT"] = str(abs(float(x)) / 100.0)
    x = gv("max_hold_bars")
    if x is not None:
        patch["MOMENTUM_MAX_HOLD_BARS"] = str(int(float(x)))
    x = gv("ratchet_tiers")
    if x is not None:
        patch["MOMENTUM_RATCHET_TIERS"] = str(x).strip()

    if "use_high_chase_filter" in body:
        patch["MOMENTUM_USE_HIGH_CHASE_FILTER"] = _env_bool_10(body.get("use_high_chase_filter"))
    if "use_daily_range_filter" in body:
        patch["MOMENTUM_USE_DAILY_RANGE_FILTER"] = _env_bool_10(body.get("use_daily_range_filter"))
    if "use_ema_filter" in body:
        patch["MOMENTUM_USE_EMA_FILTER"] = _env_bool_10(body.get("use_ema_filter"))
    if "use_rsi_max_filter" in body:
        patch["MOMENTUM_USE_RSI_MAX_FILTER"] = _env_bool_10(body.get("use_rsi_max_filter"))
    if "pattern_breakout" in body:
        patch["MOMENTUM_PATTERN_BREAKOUT"] = _env_bool_10(body.get("pattern_breakout"))
    if "pattern_pullback" in body:
        patch["MOMENTUM_PATTERN_PULLBACK"] = _env_bool_10(body.get("pattern_pullback"))
    x = gv("chase_lookback_min")
    if x is not None:
        patch["MOMENTUM_CHASE_LOOKBACK_MIN"] = str(int(float(x)))
    x = gv("pullback_lookback_min")
    if x is not None:
        patch["MOMENTUM_PULLBACK_LOOKBACK_MIN"] = str(int(float(x)))
    x = gv("pullback_min_pct")
    if x is not None:
        patch["MOMENTUM_PULLBACK_MIN_PCT"] = str(float(x))
    x = gv("pullback_max_pct")
    if x is not None:
        patch["MOMENTUM_PULLBACK_MAX_PCT"] = str(float(x))
    x = gv("setup_vol_max_mult")
    if x is not None:
        patch["MOMENTUM_SETUP_VOL_MAX_MULT"] = str(float(x))
    x = gv("setup_bear_bars_min")
    if x is not None:
        patch["MOMENTUM_SETUP_BEAR_BARS_MIN"] = str(int(float(x)))
    x = gv("ema_fast_period")
    if x is not None:
        patch["MOMENTUM_EMA_FAST_PERIOD"] = str(int(float(x)))
    x = gv("ema_slow_period")
    if x is not None:
        patch["MOMENTUM_EMA_SLOW_PERIOD"] = str(int(float(x)))

    # 당일 누적손익 다단 트레일 익절 (레칫식) — 꼬리와 동일 (공통 헬퍼)
    patch.update(_daily_trail_save_patch(body, "MOMENTUM"))

    return patch


def _scalp_reversal_tab_save_patch(body: Dict[str, Any]) -> Dict[str, str]:
    """스캘핑 reversal 탭 saveScalpConfig JSON → SCALP_* env."""
    if not isinstance(body, dict):
        return {}
    patch: Dict[str, str] = {}

    def gv(key: str) -> Any:
        v = body.get(key)
        if v is None or v == "":
            return None
        return v

    x = gv("rsi_oversold")
    if x is not None:
        patch["SCALP_RSI_OVERSOLD"] = str(float(x))
    x = gv("rsi_overbought")
    if x is not None:
        patch["SCALP_RSI_OVERBOUGHT"] = str(float(x))

    x = gv("sl_pct")
    if x is not None:
        r = abs(float(x)) / 100.0
        patch["SCALP_STOP_LOSS_PCT"] = str(r)
    x = gv("tp_pct")
    if x is not None:
        patch["SCALP_TAKE_PROFIT_PCT"] = str(abs(float(x)) / 100.0)
    x = gv("tp_max_pct")
    if x is not None:
        patch["SCALP_TP_MAX_PCT"] = str(abs(float(x)) / 100.0)

    x = gv("drop_rate")
    if x is not None:
        patch["SCALP_MIN_DROP_RATE"] = str(abs(float(x)) / 100.0)

    x = gv("shoulder_min_high")
    if x is not None:
        sr = str(abs(float(x)) / 100.0)
        patch["SCALP_SHOULDER_MIN_HIGH_PCT"] = sr
        patch["SHOULDER_MIN_HIGH_PCT"] = sr
    x = gv("shoulder_cut_pct")
    if x is not None:
        sr = str(abs(float(x)) / 100.0)
        patch["SCALP_SHOULDER_CUT_PCT"] = sr
        patch["SHOULDER_CUT_PCT"] = sr

    x = gv("trail_trigger")
    if x is not None:
        patch["SCALP_ATR_UP_MULT"] = str(abs(float(x)) / 100.0)
    x = gv("trail_stop")
    if x is not None:
        patch["SCALP_ATR_DOWN_MULT"] = str(abs(float(x)) / 100.0)

    x = gv("cooldown_min")
    if x is not None:
        patch["SCALP_COOLDOWN_SEC"] = str(int(float(x) * 60))

    x = gv("high_chase_thr")
    if x is not None:
        hx = float(x)
        ratio = hx if 0 < hx <= 1 else hx / 100.0
        sr = str(ratio)
        patch["SCALP_HIGH_PRICE_CHASE_THRESHOLD"] = sr
        patch["HIGH_CHASE_THR"] = sr
        patch["HIGH_PRICE_CHASE_THRESHOLD"] = sr

    x = gv("max_daily_chg")
    if x is not None:
        vchg = str(float(x))
        patch["SCALP_MAX_DAILY_CHANGE_PCT"] = vchg
        patch["MAX_DAILY_CHG"] = vchg
        patch["MAX_DAILY_CHANGE_PCT"] = vchg

    x = gv("min_price")
    if x is not None:
        patch["SCALP_MIN_PRICE"] = str(float(x))

    x = gv("max_loss_krw")
    if x is not None:
        ml = str(int(float(x)))
        patch["SCALP_MAX_LOSS_PER_TRADE_KRW"] = ml
        patch["MAX_LOSS_PER_TRADE_KRW"] = ml

    x = gv("min_margin")
    if x is not None:
        mm = str(float(x))
        patch["SCALP_MIN_PROFIT_PCT"] = mm
        patch["MOMENTUM_MIN_PROFIT_PCT"] = mm

    if "use_defense_filters" in body:
        patch["SCALP_USE_DEFENSE_FILTERS"] = _env_bool_10(body.get("use_defense_filters"))

    if "use_macd_cross" in body:
        patch["SCALP_USE_MACD_CROSS"] = _env_bool_10(body.get("use_macd_cross"))

    return patch


def _breakout_tab_save_patch(body: Dict[str, Any]) -> Dict[str, str]:
    """돌파 백테 탭 폼 → BREAKOUT_* env (BreakoutStrategy 가 읽는 음수 손절 비율 포함)."""
    if not isinstance(body, dict):
        return {}
    patch: Dict[str, str] = {}

    def gv(key: str) -> Any:
        v = body.get(key)
        if v is None or v == "":
            return None
        return v

    x = gv("lookback_min")
    if x is not None:
        patch["BREAKOUT_LOOKBACK_MIN"] = str(int(float(x)))
    x = gv("vol_window")
    if x is not None:
        patch["BREAKOUT_VOL_WIN"] = str(int(float(x)))
    x = gv("vol_mult")
    if x is not None:
        patch["BREAKOUT_VOL_MULT"] = str(float(x))
    x = gv("min_turnover_1m_pct")
    if x is not None:
        patch["BREAKOUT_MIN_TURNOVER_1M_PCT"] = str(float(x))
    x = gv("prev_chg_min")
    if x is not None:
        patch["BREAKOUT_PREV_CHG_MIN"] = str(float(x))
    x = gv("prev_chg_max")
    if x is not None:
        patch["BREAKOUT_PREV_CHG_MAX"] = str(float(x))

    x = gv("sl_pct")
    if x is not None:
        patch["BREAKOUT_STOP_LOSS_PCT"] = str(-abs(float(x)) / 100.0)
    x = gv("sl_mode")
    if x is not None:
        patch["BREAKOUT_SL_MODE"] = str(x).strip().lower()
    x = gv("atr_period")
    if x is not None:
        patch["BREAKOUT_ATR_PERIOD"] = str(int(float(x)))
    x = gv("atr_sl_mult")
    if x is not None:
        patch["BREAKOUT_ATR_SL_MULT"] = str(float(x))
    x = gv("atr_sl_min_pct")
    if x is not None:
        patch["BREAKOUT_ATR_SL_MIN_PCT"] = str(float(x))
    x = gv("atr_sl_max_pct")
    if x is not None:
        patch["BREAKOUT_ATR_SL_MAX_PCT"] = str(float(x))
    x = gv("tp_pct")
    if x is not None:
        patch["BREAKOUT_TAKE_PROFIT_PCT"] = str(abs(float(x)) / 100.0)
    x = gv("trail_pct")
    if x is not None:
        patch["BREAKOUT_TRAIL_PCT"] = str(abs(float(x)) / 100.0)
    x = gv("trail_arm_pct")
    if x is not None:
        patch["BREAKOUT_TRAIL_ARM_PCT"] = str(abs(float(x)) / 100.0)
    x = gv("shoulder_min_high_pct")
    if x is not None:
        patch["BREAKOUT_SHOULDER_MIN_HIGH_PCT"] = str(abs(float(x)) / 100.0)
    x = gv("shoulder_cut_pct")
    if x is not None:
        patch["BREAKOUT_SHOULDER_CUT_PCT"] = str(abs(float(x)) / 100.0)

    if "eod_enabled" in body:
        patch["BREAKOUT_EOD_ENABLED"] = _env_bool_10(body.get("eod_enabled"))
    x = gv("eod_hm")
    if x is not None:
        eod_s = str(x).strip()
        if eod_s and ":" not in eod_s and len(eod_s) == 4 and eod_s.isdigit():
            eod_s = f"{eod_s[:2]}:{eod_s[2:]}"
        patch["BREAKOUT_EOD_HM"] = eod_s
    x = gv("max_hold_bars")
    if x is not None:
        patch["BREAKOUT_MAX_HOLD_BARS"] = str(int(float(x)))

    x = gv("time_start_hm")
    if x is not None:
        patch["BREAKOUT_TIME_START"] = str(int(float(x)))
    x = gv("time_end_hm")
    if x is not None:
        te = str(int(float(x)))
        patch["BREAKOUT_TIME_END"] = te
        patch["BREAKOUT_GOLDEN_END_HM"] = te

    x = gv("max_daily")
    if x is not None:
        patch["BREAKOUT_MAX_DAILY"] = str(int(float(x)))

    x = gv("cooldown_min")
    if x is not None:
        patch["BREAKOUT_COOLDOWN_SEC"] = str(int(float(x) * 60))

    x = gv("max_daily_chg")
    if x is not None:
        patch["BREAKOUT_MAX_DAILY_CHG"] = str(float(x))

    x = gv("min_price")
    if x is not None:
        patch["BREAKOUT_MIN_PRICE"] = str(int(float(x)))

    # 가짜돌파(휩쏘) 필터 — 0=OFF
    x = gv("confirm_margin_pct")
    if x is not None:
        patch["BREAKOUT_CONFIRM_MARGIN_PCT"] = str(float(x))
    x = gv("body_min_pct")
    if x is not None:
        patch["BREAKOUT_BODY_MIN_PCT"] = str(float(x))

    x = gv("max_loss_krw")
    if x is not None:
        patch["BREAKOUT_MAX_LOSS_PER_TRADE_KRW"] = str(int(float(x)))

    x = gv("entry_mode")
    if x is not None:
        patch["BREAKOUT_ENTRY_MODE"] = str(x).strip().lower()
    x = gv("intrabar_slippage_pct")
    if x is not None:
        patch["BREAKOUT_INTRABAR_SLIPPAGE_PCT"] = str(float(x))

    x = gv("slot_money")
    if x is not None:
        patch["BREAKOUT_SLOT_MONEY"] = str(int(float(x)))
    x = gv("max_stocks")
    if x is not None:
        patch["BREAKOUT_MAX_STOCKS"] = str(int(float(x)))
    x = gv("total_budget_krw")
    if x is not None:
        patch["BREAKOUT_TOTAL_BUDGET_KRW"] = str(int(float(x)))

    if "use_ema_filter" in body:
        patch["BREAKOUT_USE_EMA_FILTER"] = _env_bool_10(body.get("use_ema_filter"))
    if "ob_filter" in body:
        patch["BREAKOUT_ORDERBOOK_FILTER_ENABLED"] = _env_bool_10(body.get("ob_filter"))
    if "pg_filter" in body:
        patch["BREAKOUT_PROGRAM_FILTER_ENABLED"] = _env_bool_10(body.get("pg_filter"))
    x = gv("ema_fast_period")
    if x is not None:
        patch["BREAKOUT_EMA_FAST_PERIOD"] = str(int(float(x)))
    x = gv("ema_slow_period")
    if x is not None:
        patch["BREAKOUT_EMA_SLOW_PERIOD"] = str(int(float(x)))

    # 당일 누적손익 다단 트레일 익절 (레칫식) — 꼬리와 동일 (공통 헬퍼)
    patch.update(_daily_trail_save_patch(body, "BREAKOUT"))

    return patch


def _range_break_tab_save_patch(body: Dict[str, Any]) -> Dict[str, str]:
    """박스권 돌파 백테 탭 폼 → RANGE_BREAK_* env."""
    if not isinstance(body, dict):
        return {}
    patch: Dict[str, str] = {}

    def gv(key: str) -> Any:
        v = body.get(key)
        if v is None or v == "":
            return None
        return v

    mapping = (
        ("box_lookback_min", "RANGE_BREAK_BOX_LOOKBACK_MIN", lambda x: str(int(float(x)))),
        ("box_max_width_pct", "RANGE_BREAK_BOX_MAX_WIDTH_PCT", lambda x: str(float(x))),
        ("box_min_width_pct", "RANGE_BREAK_BOX_MIN_WIDTH_PCT", lambda x: str(float(x))),
        ("setup_vol_max_mult", "RANGE_BREAK_SETUP_VOL_MAX_MULT", lambda x: str(float(x))),
        ("setup_bear_bars_min", "RANGE_BREAK_SETUP_BEAR_BARS_MIN", lambda x: str(int(float(x)))),
        ("vol_mult", "RANGE_BREAK_VOL_MULT", lambda x: str(float(x))),
        ("vol_window", "RANGE_BREAK_VOL_WIN", lambda x: str(int(float(x)))),
        ("vol_baseline_win", "RANGE_BREAK_VOL_BASELINE_WIN", lambda x: str(int(float(x)))),
        ("break_margin_pct", "RANGE_BREAK_BREAK_MARGIN_PCT", lambda x: str(float(x))),
        ("body_min_pct", "RANGE_BREAK_BODY_MIN_PCT", lambda x: str(float(x))),
        ("time_start_hm", "RANGE_BREAK_TIME_START", lambda x: str(int(float(x)))),
        ("time_end_hm", "RANGE_BREAK_TIME_END_HM", lambda x: str(int(float(x)))),
        ("max_daily", "RANGE_BREAK_MAX_DAILY", lambda x: str(int(float(x)))),
        ("max_daily_chg", "RANGE_BREAK_MAX_DAILY_CHG", lambda x: str(float(x))),
        ("min_price", "RANGE_BREAK_MIN_PRICE", lambda x: str(int(float(x)))),
        ("high_chase_thr", "RANGE_BREAK_HIGH_CHASE_THR", lambda x: str(float(x))),
        ("max_loss_krw", "RANGE_BREAK_MAX_LOSS_PER_TRADE_KRW", lambda x: str(int(float(x)))),
        ("slot_money", "RANGE_BREAK_SLOT_MONEY", lambda x: str(int(float(x)))),
        ("max_stocks", "RANGE_BREAK_MAX_STOCKS", lambda x: str(int(float(x)))),
        ("total_budget_krw", "RANGE_BREAK_TOTAL_BUDGET_KRW", lambda x: str(int(float(x)))),
        ("max_hold_bars", "RANGE_BREAK_MAX_HOLD_BARS", lambda x: str(int(float(x)))),
    )
    for ui_key, env_key, fmt in mapping:
        x = gv(ui_key)
        if x is not None:
            patch[env_key] = fmt(x)

    x = gv("sl_pct")
    if x is not None:
        patch["RANGE_BREAK_STOP_LOSS_PCT"] = str(-abs(float(x)) / 100.0)
    x = gv("tp_pct")
    if x is not None:
        patch["RANGE_BREAK_TAKE_PROFIT_PCT"] = str(abs(float(x)) / 100.0)
    x = gv("trail_pct")
    if x is not None:
        patch["RANGE_BREAK_TRAIL_PCT"] = str(abs(float(x)) / 100.0)
    x = gv("trail_arm_pct")
    if x is not None:
        patch["RANGE_BREAK_TRAIL_ARM_PCT"] = str(abs(float(x)) / 100.0)
    x = gv("shoulder_min_high_pct")
    if x is not None:
        patch["RANGE_BREAK_SHOULDER_MIN_HIGH_PCT"] = str(abs(float(x)) / 100.0)
    x = gv("shoulder_cut_pct")
    if x is not None:
        patch["RANGE_BREAK_SHOULDER_CUT_PCT"] = str(abs(float(x)) / 100.0)
    x = gv("cooldown_min")
    if x is not None:
        patch["RANGE_BREAK_COOLDOWN_SEC"] = str(int(float(x) * 60))
    if "use_high_chase_filter" in body:
        patch["RANGE_BREAK_USE_HIGH_CHASE_FILTER"] = _env_bool_10(body.get("use_high_chase_filter"))

    return patch


@app.route("/api/backtest/scalping/save_config", methods=["POST"])
def api_backtest_scalping_save_config():
    """스캘핑 reversal · 모멘텀 → insert_env_snapshot (config_scalp / config_momentum 분리 저장)."""
    body = request.get_json(force=True, silent=True) or {}
    try:
        sk = str(body.get("save_kind") or "").strip().lower()
        keys = set(body.keys())

        if sk == "momentum":
            patch = _momentum_tab_save_patch(body)
        elif sk == "reversal" or "rsi_oversold" in keys:
            patch = _scalp_reversal_tab_save_patch(body)
        elif "use_defense_filters" in keys or "use_macd_cross" in keys:
            patch = {}
            if "use_defense_filters" in keys:
                patch["SCALP_USE_DEFENSE_FILTERS"] = _env_bool_10(body.get("use_defense_filters"))
            if "use_macd_cross" in keys:
                patch["SCALP_USE_MACD_CROSS"] = _env_bool_10(body.get("use_macd_cross"))
        else:
            return jsonify({"error": "알 수 없는 저장 요청(save_kind 또는 필드 없음)"}), 400

        if not patch:
            return jsonify({"error": "저장할 필드 없음"}), 400

        db = _db()
        try:
            latest = db.get_latest_env()
            snap = dict(latest["snapshot"]) if latest else {}
            for k, v in patch.items():
                snap[k] = v
            env_id = db.insert_env_snapshot(snap)
            if env_id is None:
                return jsonify({"error": "env 저장 실패(insert_env_snapshot)"}), 500
            from config_schema import classify_config_key
            saved_by_table: Dict[str, List[str]] = {}
            for k in patch:
                tbl = classify_config_key(k)
                saved_by_table.setdefault(tbl, []).append(k)
            return jsonify({
                "ok": True,
                "env_id": env_id,
                "saved_keys": list(patch.keys()),
                "saved_by_table": saved_by_table,
            })
        finally:
            db.close()
    except Exception as e:
        logger.error("스캘핑/모멘텀 설정저장 오류: %s", e)
        return jsonify({"error": str(e)}), 500


@app.route("/api/backtest/breakout/save_config", methods=["POST"])
def api_backtest_breakout_save_config():
    """돌파 백테 탭 폼 → BREAKOUT_* env 스냅샷 INSERT."""
    body = request.get_json(force=True, silent=True) or {}
    try:
        patch = _breakout_tab_save_patch(body)
        if not patch:
            return jsonify({"error": "저장할 필드 없음"}), 400
        db = _db()
        try:
            latest = db.get_latest_env()
            snap = dict(latest["snapshot"]) if latest else {}
            # 사용자가 직접 입력한 다단 트레일 값을 프리셋 목록(env)에 영구 누적 (꼬리와 공유)
            _accumulate_preset(snap, patch, "BT_DAILY_TRAIL_PRESETS", body.get("daily_trail_tiers"))
            for k, v in patch.items():
                snap[k] = v
            env_id = db.insert_env_snapshot(snap)
            if env_id is None:
                return jsonify({"error": "env_config 저장 실패"}), 500
            from config_schema import classify_config_key
            saved_by_table: Dict[str, List[str]] = {}
            for k in patch:
                tbl = classify_config_key(k)
                saved_by_table.setdefault(tbl, []).append(k)
            return jsonify({
                "ok": True,
                "env_id": env_id,
                "saved_keys": list(patch.keys()),
                "saved_by_table": saved_by_table,
            })
        finally:
            db.close()
    except Exception as e:
        logger.error("돌파 설정저장 오류: %s", e)
        return jsonify({"error": str(e)}), 500


@app.route("/api/backtest/range_break/save_config", methods=["POST"])
def api_backtest_range_break_save_config():
    """박스권 돌파 백테 탭 폼 → RANGE_BREAK_* env 스냅샷 INSERT."""
    body = request.get_json(force=True, silent=True) or {}
    try:
        patch = _range_break_tab_save_patch(body)
        if not patch:
            return jsonify({"error": "저장할 필드 없음"}), 400
        db = _db()
        try:
            latest = db.get_latest_env()
            snap = dict(latest["snapshot"]) if latest else {}
            for k, v in patch.items():
                snap[k] = v
            env_id = db.insert_env_snapshot(snap)
            if env_id is None:
                return jsonify({"error": "env_config 저장 실패"}), 500
            from config_schema import classify_config_key
            saved_by_table: Dict[str, List[str]] = {}
            for k in patch:
                tbl = classify_config_key(k)
                saved_by_table.setdefault(tbl, []).append(k)
            return jsonify({
                "ok": True,
                "env_id": env_id,
                "saved_keys": list(patch.keys()),
                "saved_by_table": saved_by_table,
            })
        finally:
            db.close()
    except Exception as e:
        logger.error("박스권 돌파 설정저장 오류: %s", e)
        return jsonify({"error": str(e)}), 500


@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.003) * 100)) / 100
    shoulder_cut_pct    = float(request.args.get("shoulder_cut_pct",   _def.get("shoulder_cut_pct", 0.002) * 100)) / 100
    trail_pct           = abs(float(request.args.get(
        "trail_pct", _tail_frac_to_ui_pct(_def.get("trail_pct", 0.0)) or 0,
    ))) / 100.0
    trail_arm_pct       = abs(float(request.args.get(
        "trail_arm_pct", _tail_frac_to_ui_pct(_def.get("trail_arm_pct", 0.0)) or 0,
    ))) / 100.0
    _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",  _def.get("slot_money", 3_000_000)))
    max_stocks          = int(  request.args.get("max_stocks",          _def.get("max_stocks", 3)))
    total_budget_krw    = float(request.args.get("total_budget_krw",    _def.get("total_budget_krw", 0)))
    if total_budget_krw <= 0:
        total_budget_krw = float(max_stocks * slot_money)
    _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)))
    eod_patch = _eod_params_from_request(request, _def, default_hm="15:25")
    _legacy_force_eod = request.args.get("force_eod_exit")
    if _legacy_force_eod not in (None, ""):
        eod_patch["eod_enabled"] = str(_legacy_force_eod).strip().lower() in (
            "1", "true", "y", "yes", "on",
        )

    # 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", 1.5)))
    target_atr_mult     = float(request.args.get("target_atr_mult",     _def.get("target_atr_mult", 2.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)))

    try:
        tail_tf = int(request.args.get("timeframe", request.args.get("tf", 3)))
    except (TypeError, ValueError):
        tail_tf = 3
    if tail_tf not in (3, 5, 15, 60):
        return jsonify({
            "error": f"timeframe(tf)는 ws_candles 저장 단위 3·5·15·60 중 하나여야 합니다 (요청: {tail_tf})",
        }), 400

    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=%s "
            "AND candle_time >= %s AND candle_time <= %s ORDER BY code",
            [tail_tf, start_key, end_key]
        ).fetchall()
        codes = [r["code"] for r in codes_raw]

        use_saved_history_tail, tail_univ_mode, _ = _parse_backtest_universe_arg(
            request, default="history", sim_kind=None,
        )
        use_engine = _TAIL_ENGINE_AVAILABLE and request.args.get("use_engine", "1") == "1"
        all_trades: List[Dict] = []
        universe_source = "all"
        universe_history_slots = 0
        candles_by_code: Dict[str, List[Dict]] = {}
        bt_meta: Dict[str, Any] = {}
        tail_trigger_flags: Dict[str, Any] = {
            "skip_hts_scan_dupes": _tail_bool_arg(
                request, "skip_hts_scan_dupes", _def.get("skip_hts_scan_dupes", True),
            ),
            "use_intraday_drop": _tail_bool_arg(
                request, "use_intraday_drop", _def.get("use_intraday_drop", False),
            ),
            "use_ma20_filter": _tail_bool_arg(
                request, "use_ma20_filter", _def.get("use_ma20_filter", False),
            ),
            "use_rsi_filter": _tail_bool_arg(
                request, "use_rsi_filter", _def.get("use_rsi_filter", True),
            ),
            "use_daily_range_filter": _tail_bool_arg(
                request, "use_daily_range_filter", _def.get("use_daily_range_filter", True),
            ),
            "use_high_chase_filter": _tail_bool_arg(
                request, "use_high_chase_filter", _def.get("use_high_chase_filter", True),
            ),
            "bar_chg_min_pct": float(request.args.get("bar_chg_min_pct", _def.get("bar_chg_min_pct", -10.0))),
            "bar_chg_max_pct": float(request.args.get("bar_chg_max_pct", _def.get("bar_chg_max_pct", -1.5))),
            "pattern_hammer": _tail_bool_arg(request, "pattern_hammer", _def.get("pattern_hammer", True)),
            "pattern_pin": _tail_bool_arg(request, "pattern_pin", _def.get("pattern_pin", False)),
            "pattern_engulfing": _tail_bool_arg(request, "pattern_engulfing", _def.get("pattern_engulfing", False)),
            "pattern_piercing": _tail_bool_arg(request, "pattern_piercing", _def.get("pattern_piercing", False)),
            "pattern_harami": _tail_bool_arg(request, "pattern_harami", _def.get("pattern_harami", False)),
            "pattern_doji": _tail_bool_arg(request, "pattern_doji", _def.get("pattern_doji", False)),
            "pattern_morning_star": _tail_bool_arg(
                request, "pattern_morning_star", _def.get("pattern_morning_star", False),
            ),
        }

        try:
            from kis_trader.engine.limit_entry_common import short_entry_mode, tail_limit_params
        except ImportError:
            short_entry_mode = lambda p=None: "limit_atr"  # type: ignore
            tail_limit_params = lambda p=None: {}  # type: ignore

        _entry_mode = str(
            request.args.get("entry_mode") or _def.get("entry_mode") or short_entry_mode()
        ).strip().lower()
        _limit_probe = dict(_def)
        _limit_probe["entry_mode"] = _entry_mode
        for _lk in ("limit_atr_mult", "limit_anchor", "limit_valid_bars", "limit_fill_slip_pct"):
            _lv = request.args.get(_lk)
            if _lv is not None and str(_lv).strip() != "":
                _limit_probe[_lk] = _lv
        _lp_tail = tail_limit_params(_limit_probe)

        if use_engine:
            params = {
                "entry_mode": _entry_mode,
                "limit_atr_mult": _lp_tail["mult"],
                "limit_anchor": _lp_tail["anchor"],
                "limit_valid_bars": _lp_tail["valid_bars"],
                "limit_fill_slip_pct": _lp_tail["fill_slip_pct"],
                "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,
                "atr_sl_min_pct": float(request.args.get("atr_sl_min_pct", _def.get("atr_sl_min_pct", 0.5))),
                "atr_sl_max_pct": float(request.args.get("atr_sl_max_pct", _def.get("atr_sl_max_pct", 1.0))),
                "atr_tp_min_pct": float(request.args.get("atr_tp_min_pct", _def.get("atr_tp_min_pct", 0.3))),
                "atr_tp_max_pct": float(request.args.get("atr_tp_max_pct", _def.get("atr_tp_max_pct", 1.0))),
                "tail_vol_mult": float(request.args.get("tail_vol_mult", _def.get("tail_vol_mult", 0.0))),
                "tail_vol_win": int(float(request.args.get("tail_vol_win", _def.get("tail_vol_win", 5)))),
                "backtest_vol_fill_cap_pct": float(request.args.get(
                    "backtest_vol_fill_cap_pct", _def.get("backtest_vol_fill_cap_pct", 0.0),
                )),
                "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,
                **eod_patch,
                "max_stocks": max_stocks,
                "total_budget_krw": total_budget_krw,
                "portfolio_mode": True,
                "ratchet_tiers": str(
                    request.args.get("ratchet_tiers", _def.get("ratchet_tiers", "")) or ""
                ).strip(),
                "max_hold_bars": int(float(
                    request.args.get("max_hold_bars", _def.get("max_hold_bars", 0)) or 0
                )),
                "backtest_use_tick_db": _tail_bool_arg(
                    request, "backtest_use_tick_db", _def.get("backtest_use_tick_db", False),
                ),
                "backtest_tick_fallback_ohlc": _tail_bool_arg(
                    request, "backtest_tick_fallback_ohlc", _def.get("backtest_tick_fallback_ohlc", True),
                ),
                "trail_pct": trail_pct,
                "trail_arm_pct": trail_arm_pct,
                # 당일 누적손익 트레일 익절(daily_trail_*) — drop>0 일 때만 게이트 ON.
                # apply_daily_profit_halt_sim 이 engine_params 로 읽어 신규진입 차단.
                **_daily_trail_params_from_request(request),
                **tail_trigger_flags,
            }
            start_ymd = start_key[:8]
            end_ymd = end_key[:8]
            universe_by_slot, universe_source, universe_history_slots, _scan_iv = (
                tbc.resolve_tail_universe(
                    start_ymd, end_ymd, use_saved_history=use_saved_history_tail,
                )
            )
            params["scan_interval_min"] = _scan_iv
            params["timeframe"] = tail_tf
            # 백테 전용 필터 토글 (이 1회 백테에만 적용. 비우면 DB=실매값)
            _ob_tg = _backtest_filter_toggle(request.args.get("ob_filter"))
            if _ob_tg is not None:
                params["_orderbook_filter_enabled"] = _ob_tg
            _pg_tg = _backtest_filter_toggle(request.args.get("pg_filter"))
            if _pg_tg is not None:
                params["_program_filter_enabled"] = _pg_tg
            _spread_req = request.args.get("max_spread_pct")
            if _spread_req not in (None, ""):
                params["_ob_max_spread_pct"] = float(_spread_req)
            # kiwoom_0d 본체는 ob_body=1 일 때만. spread 값만으로 본체 강제하면
            # log_backfill 판정 재생(파람서치·CLI --orderbook-filter on 기본)과 어긋남.
            _ob_body = str(request.args.get("ob_body", "0")).strip().lower() in (
                "1", "true", "y", "yes", "on",
            )
            if _ob_body:
                params["backtest_use_kiwoom_body_snapshot"] = True
                params["_backtest_use_kiwoom_body"] = True
            candles_by_code, _total_candles, has_holding_peak = tbc.load_tail_candles_by_code(
                db, start_key, end_key, tail_tf, rsi_period=rsi_period,
            )
            bt_meta = {
                "db": db,
                "start_key": start_key,
                "end_key": end_key,
                "timeframe": tail_tf,
            }
            all_trades = tbc.run_tail_backtest_web_aligned(
                candles_by_code, params, universe_by_slot,
                slot_money=slot_money, fee_rate=fee_rate, sell_tax=sell_tax,
                max_stocks=max_stocks, total_budget_krw=total_budget_krw,
                meta_out=bt_meta,
            )
        else:
            for code in codes:
                rows = db.conn.execute(
                    "SELECT candle_time, open, high, low, close, volume "
                    "FROM ws_candles "
                    "WHERE timeframe=%s AND code=%s "
                    "AND candle_time >= %s AND candle_time <= %s "
                    "AND is_confirmed=1 "
                    "ORDER BY candle_time ASC",
                    [tail_tf, 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 = is_strategy_eod_bar(c["candle_time"], params, "TAIL")

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

                        cur_c_info = {"high": hi, "low": lo, "close": cl, "candle_time": c["candle_time"]}
                        _pos = dict(position)
                        _pos["max_price"] = max_p
                        res = te.check_sell_signal_live(_pos, cur_c_info, params, is_eod=is_eod)
                        if res:
                            reason, exit_price = res
                        else:
                            reason, exit_price = None, 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
        peak_cum_at = ""  # 장중 누적손익 최고점에 도달한 시각(모멘텀 동일 표기)
        equity, daily_map = [], {}
        for t in sorted(all_trades, key=lambda x: x["exit_time"]):
            cum += t["pnl"]
            if cum > peak:
                peak = cum
                peak_cum_at = str(t.get("exit_time") or "")
            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())]

        bot_pct = round(total_pnl / total_budget_krw * 100, 2) if total_budget_krw > 0 else 0.0
        period_days = _backtest_period_days(start, end, fallback=len(daily_list) or 1)
        daily_avg_pct = round(bot_pct / period_days, 3) if period_days > 0 else 0.0

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

        universe_warning = None
        budget_warning = None
        if total_budget_krw < max_stocks * slot_money * 0.95:
            budget_warning = (
                f"총한도 {total_budget_krw:,.0f}원 < 동시{max_stocks}×1회투자 "
                f"{max_stocks * slot_money:,.0f}원 — 잔여금 소액매수·과다 회전 위험. "
                "실매 정렬: 총한도↑ 또는 동시보유↓"
            )
        skip_stats = {}
        if use_engine:
            skip_stats = (bt_meta.get("skip_stats") or params.get("_portfolio_skip_stats") or {})
        skipped_micro = int(skip_stats.get("skipped_micro_buys") or 0)
        min_inv_r = te._tail_min_invest_ratio_of_slot(params) if use_engine else 0.9
        if skipped_micro > 0:
            micro_note = f"소액매수 스킵 {skipped_micro}건 (slot {min_inv_r * 100:.0f}% 미만)"
            budget_warning = f"{budget_warning} | {micro_note}" if budget_warning else micro_note

        if use_saved_history_tail and universe_source == "all":
            universe_warning = (
                "저장 후보 이력을 요청했으나 해당 기간 이력이 없어 전종목(ws_candles)으로 실행되었습니다."
            )
        elif not use_saved_history_tail and universe_source == "all":
            universe_warning = (
                "전종목 모드(저장 이력 OFF). 거래 수가 많습니다. "
                "파라서치 기본(저장 이력)과 비교하려면 체크박스를 켜세요."
            )
        elif universe_source == "history" and total > 25:
            universe_warning = (
                f"저장 이력 {universe_history_slots}슬롯 사용 중 거래 {total}건 — "
                "매수시간·기간·파라미터가 파라서치와 다른지 확인하세요."
            )

        tail_trades_out = _trades_recent_first(all_trades, 200)
        _enrich_trades_with_names(db, tail_trades_out)
        # 모멘텀과 동일하게 매도시각 순 누적손익·누적수익률·체결(틱/OHLC) 디버그 라벨 부착
        _enrich_momentum_trades_debug(tail_trades_out, total_budget_krw=total_budget_krw)

        return jsonify({
            "params": {
                "start": start, "end": end,
                "timeframe": tail_tf,
                "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,
                "trail_pct": trail_pct * 100,
                "trail_arm_pct": trail_arm_pct * 100,
                "slot_money": slot_money,
                "max_stocks": max_stocks,
                "total_budget_krw": total_budget_krw,
                "portfolio_mode": True,
                "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}",
                **eod_patch,
                "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(candles_by_code) if use_engine else len(codes),
                "universe_source": universe_source,
                "universe_history_slots": universe_history_slots,
                "universe": tail_univ_mode,
                "strategy_id": "SHORT",
                "entry_mode": _entry_mode,
                "limit_atr_mult": _lp_tail["mult"],
                "limit_anchor": _lp_tail["anchor"],
                "limit_valid_bars": _lp_tail["valid_bars"],
                "limit_fill_slip_pct": _lp_tail["fill_slip_pct"],
                **tail_trigger_flags,
            },
            "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),
                "peak_cum_pnl":  round(peak),
                "peak_cum_at":   peak_cum_at[:19] if peak_cum_at else "",
                "bot_pct":       bot_pct,
                "daily_avg_pct": daily_avg_pct,
                "backtest_days": period_days,
                "universe_warning": universe_warning,
                "budget_warning": budget_warning,
                "tick_backtest": bt_meta.get("tick_backtest") if use_engine else None,
                "backtest_buy_source": bt_meta.get("backtest_buy_source") if use_engine else None,
                "tick_entry_sources": skip_stats.get("tick_entry_sources") if use_engine else None,
                "skip_stats": skip_stats if use_engine else None,
            },
            "equity":  equity,
            "daily":   daily_list,
            "reasons": reasons,
            "trades":  tail_trades_out,
        })
    finally:
        db.close()


@app.route("/api/backtest/breakout", methods=["GET"])
def api_backtest_breakout():
    """돌파매매 백테스트 — ``run_breakout_backtest`` (라이브 BreakoutStrategy 와 동일 함수)."""
    _def = _bo_defaults_from_db()
    start = request.args.get("start", "")
    end   = request.args.get("end", "")

    def _arg(key: str, default: Any, cast=float):
        raw = request.args.get(key)
        if raw in (None, ""):
            return default
        try:
            return cast(raw)
        except (ValueError, TypeError):
            return default

    ui = {
        "lookback_min":   _arg("lookback_min",   _def["lookback_min"],   lambda v: int(float(v))),
        "vol_window":     _arg("vol_window",     _def["vol_window"],     lambda v: int(float(v))),
        "vol_mult":       _arg("vol_mult",       _def["vol_mult"],       float),
        "min_turnover_1m_pct": _arg(
            "min_turnover_1m_pct", _def.get("min_turnover_1m_pct", 0.05), float,
        ),
        "prev_chg_min":   _arg("prev_chg_min",   _def["prev_chg_min"],   float),
        "prev_chg_max":   _arg("prev_chg_max",   _def["prev_chg_max"],   float),
        "sl_pct":         _arg("sl_pct",         _def["sl_pct"],         float),
        "sl_mode":        _arg("sl_mode",        _def.get("sl_mode", "fixed"), str).strip().lower(),
        "atr_period":     _arg("atr_period",     _def.get("atr_period", 14), lambda v: int(float(v))),
        "atr_sl_mult":    _arg("atr_sl_mult",    _def.get("atr_sl_mult", 2.0), float),
        "atr_sl_min_pct": _arg("atr_sl_min_pct", _def.get("atr_sl_min_pct", 0.8), float),
        "atr_sl_max_pct": _arg("atr_sl_max_pct", _def.get("atr_sl_max_pct", 6.0), float),
        "tp_pct":         _arg("tp_pct",         _def["tp_pct"],         float),
        "trail_pct":      _arg("trail_pct",      _def["trail_pct"],      float),
        "trail_arm_pct":  _arg("trail_arm_pct",  _def.get("trail_arm_pct", 0.0), float),
        "time_start_hm":  _arg("time_start_hm",  _def["time_start_hm"],  lambda v: int(float(v))),
        "time_end_hm":    _arg("time_end_hm",    _def["time_end_hm"],    lambda v: int(float(v))),
        "max_daily":      _arg("max_daily",      _def["max_daily"],      lambda v: int(float(v))),
        "cooldown_min":   _arg("cooldown_min",   _def["cooldown_min"],   float),
        "max_daily_chg":  _arg("max_daily_chg",  _def["max_daily_chg"],  float),
        "min_price":      _arg("min_price",      _def["min_price"],      float),
        # 가짜돌파(휩쏘) 필터 — 0=OFF
        "confirm_margin_pct": _arg("confirm_margin_pct", _def.get("confirm_margin_pct", 0.0), float),
        "body_min_pct":   _arg("body_min_pct",   _def.get("body_min_pct", 0.0),   float),
        "max_loss_krw":   _arg("max_loss_krw",   _def["max_loss_krw"],   lambda v: int(float(v))),
        "slot_money":     _arg("slot_money",     _def["slot_money"],     lambda v: int(float(v))),
        "shoulder_min_high_pct": _arg(
            "shoulder_min_high_pct", _def.get("shoulder_min_high_pct", 0.5), float,
        ),
        "shoulder_cut_pct": _arg(
            "shoulder_cut_pct", _def.get("shoulder_cut_pct", 0.2), float,
        ),
        "max_hold_bars":  _arg("max_hold_bars", _def.get("max_hold_bars", 0), lambda v: int(float(v))),
        "ratchet_tiers":  _arg("ratchet_tiers", _def.get("ratchet_tiers", ""), str),
        "fee_rate_pct":   _def.get("fee_rate_pct", 0.015),
        "sell_tax_pct":   _def.get("sell_tax_pct", 0.18),
        "entry_mode":     _arg("entry_mode", _def.get("entry_mode", "intrabar"), str).strip().lower(),
        "intrabar_slippage_pct": _arg(
            "intrabar_slippage_pct", _def.get("intrabar_slippage_pct", 0.0), float,
        ),
    }
    _uef = request.args.get("use_ema_filter")
    if _uef not in (None, ""):
        ui["use_ema_filter"] = str(_uef).strip().lower() in ("1", "true", "y", "yes", "on")
    else:
        ui["use_ema_filter"] = bool(_def.get("use_ema_filter", False))
    ui["ema_fast_period"] = _arg("ema_fast_period", _def.get("ema_fast_period", 9), lambda v: int(float(v)))
    ui["ema_slow_period"] = _arg("ema_slow_period", _def.get("ema_slow_period", 21), lambda v: int(float(v)))
    ui.update(_eod_params_from_request(request, _def, default_hm="15:15"))
    engine = _bo_ui_to_engine_params(ui)

    # 백테 전용 필터 토글 (이 1회 백테에만 적용. 비우면 DB=실매값)
    _ob_tg = _backtest_filter_toggle(request.args.get("ob_filter"))
    if _ob_tg is not None:
        engine["_orderbook_filter_enabled"] = _ob_tg
    _pg_tg = _backtest_filter_toggle(request.args.get("pg_filter"))
    if _pg_tg is not None:
        engine["_program_filter_enabled"] = _pg_tg
    _spread_req = request.args.get("max_spread_pct")
    if _spread_req not in (None, ""):
        engine["_ob_max_spread_pct"] = float(_spread_req)
        engine["backtest_use_kiwoom_body_snapshot"] = True
        engine["_backtest_use_kiwoom_body"] = True

    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) < 5:
                continue
            codes_candles[code] = [dict(r) for r in rows]

        use_saved_history, bo_univ_mode, _ = _parse_backtest_universe_arg(
            request, default="history", sim_kind=None,
        )
        universe_by_slot, universe_source, universe_history_slots, _scan_iv = (
            _resolve_backtest_universe(
                db,
                start_key,
                end_key,
                use_saved_history,
                codes_candles,
                strategy_id="BREAKOUT",
            )
        )

        latest_env = db.get_latest_env()
        env_row = dict(latest_env["snapshot"]) if latest_env else {}
        fee_rate, sell_tax, slot_from_env = bbc.fee_and_slot_from_env(env_row)
        slot_money_v = float(ui.get("slot_money") or slot_from_env)
        max_stocks_req = _arg(
            "max_stocks", _def.get("max_stocks", 3), lambda v: int(float(v)),
        )
        total_budget_req = _arg(
            "total_budget_krw", _def.get("total_budget_krw", 0), float,
        )
        portfolio = bbc.resolve_breakout_portfolio_params(
            env_row,
            None,
            slot_money=slot_money_v,
            max_stocks=max_stocks_req if max_stocks_req > 0 else None,
            total_budget_krw=total_budget_req if total_budget_req > 0 else None,
        )
        total_budget_v = float(portfolio["total_budget_krw"])
        max_stocks_v = int(portfolio["max_stocks"])
        engine["slot_money"] = slot_money_v
        engine["max_stocks"] = max_stocks_v
        engine["total_budget_krw"] = total_budget_v
        engine["portfolio_mode"] = True

        bt_meta: Dict[str, Any] = {
            "db": db,
            "start_key": start_key,
            "end_key": end_key,
        }
        all_trades = bbc.run_breakout_backtest_web_aligned(
            codes_candles,
            engine,
            universe_by_slot=universe_by_slot,
            slot_money=slot_money_v,
            fee_rate=fee_rate,
            sell_tax=sell_tax,
            max_stocks=max_stocks_v,
            total_budget_krw=total_budget_v,
            meta_out=bt_meta,
        )

        # 당일 누적손익 트레일 익절 시뮬 (백테 탭 입력 → drop>0 일 때만 ON).
        _trail_p = _daily_trail_params_from_request(request)
        if _trail_p:
            from kis_trader.backtest.backtest_portfolio_common import apply_daily_profit_halt_sim
            all_trades = apply_daily_profit_halt_sim(
                all_trades, _trail_p, budget_krw=float(total_budget_v or 0),
            )

        period_days = _backtest_period_days(start, end, fallback=1)
        stats = bbc.summarize_breakout_trades(
            all_trades,
            total_budget_krw=total_budget_v,
            period_days=period_days,
        )
        total = int(stats["total_trades"])
        total_pnl = int(stats["total_pnl"])
        wins_n = int(stats["wins"])
        losses_n = int(stats["losses"])
        avg_hold = float(stats["avg_hold_min"])
        pf = float(stats["pf"])
        bot_pct = float(stats["bot_pct"])
        daily_avg_pct = float(stats["daily_avg_pct"])
        wins = [t for t in all_trades if t.get("pnl", 0) > 0]
        losses = [t for t in all_trades if t.get("pnl", 0) < 0]

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

        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:
            rk = str(t.get("sell_reason") or "unknown")
            reasons[rk] = reasons.get(rk, 0) + 1

        trades_out = _trades_recent_first(all_trades, 200)
        _enrich_trades_with_names(db, trades_out)

        ts_hm = int(ui["time_start_hm"])
        te_hm = int(ui["time_end_hm"])
        return jsonify({
            "params": {
                **ui,
                "entry_mode": engine.get("entry_mode", breakout_entry_mode()),
                "max_stocks": max_stocks_v,
                "total_budget_krw": total_budget_v,
                "start": start,
                "end": end,
                "time_window": f"{ts_hm:04d}-{te_hm:04d}",
                "codes_analyzed": len(codes),
                "universe_source": universe_source,
                "universe_history_slots": universe_history_slots,
                "universe": bo_univ_mode,
                "strategy_id": "BREAKOUT",
            },
            "summary": {
                "total_trades":  total,
                "win_trades":    wins_n,
                "loss_trades":   losses_n,
                "win_rate":      float(stats["win_rate"]),
                "total_pnl":     total_pnl,
                "avg_hold_min":  round(avg_hold, 1),
                "profit_factor": round(pf, 2),
                "max_drawdown":  round(mdd),
                "bot_pct":       bot_pct,
                "daily_avg_pct": daily_avg_pct,
                "backtest_days": period_days,
                "total_budget_krw": total_budget_v,
                "slot_money": slot_money_v,
                "max_stocks": max_stocks_v,
                "budget_warning": portfolio.get("budget_warning"),
                "backtest_buy_source": bt_meta.get("backtest_buy_source"),
                "tick_backtest": bt_meta.get("tick_backtest"),
            },
            "equity": equity,
            "daily": daily_list,
            "reasons": reasons,
            "trades": trades_out,
        })
    except Exception as e:
        logger.exception("breakout backtest failed")
        return jsonify({"error": str(e)}), 500
    finally:
        db.close()


@app.route("/api/backtest/range_break", methods=["GET"])
def api_backtest_range_break():
    """박스권 돌파 백테스트 — ``run_range_break_backtest`` (라이브 RangeBreakStrategy 와 동일)."""
    _def = _rb_defaults_from_db()
    start = request.args.get("start", "")
    end = request.args.get("end", "")

    def _arg(key: str, default: Any, cast=float):
        raw = request.args.get(key)
        if raw in (None, ""):
            return default
        try:
            return cast(raw)
        except (ValueError, TypeError):
            return default

    ui = {
        "box_lookback_min": _arg("box_lookback_min", _def["box_lookback_min"], lambda v: int(float(v))),
        "box_max_width_pct": _arg("box_max_width_pct", _def["box_max_width_pct"], float),
        "box_min_width_pct": _arg("box_min_width_pct", _def["box_min_width_pct"], float),
        "setup_vol_max_mult": _arg("setup_vol_max_mult", _def["setup_vol_max_mult"], float),
        "setup_bear_bars_min": _arg("setup_bear_bars_min", _def["setup_bear_bars_min"], lambda v: int(float(v))),
        "vol_mult": _arg("vol_mult", _def["vol_mult"], float),
        "vol_window": _arg("vol_window", _def["vol_window"], lambda v: int(float(v))),
        "vol_baseline_win": _arg("vol_baseline_win", _def.get("vol_baseline_win", 30), lambda v: int(float(v))),
        "break_margin_pct": _arg("break_margin_pct", _def.get("break_margin_pct", 0.0), float),
        "body_min_pct": _arg("body_min_pct", _def.get("body_min_pct", 0.0), float),
        "sl_pct": _arg("sl_pct", _def["sl_pct"], float),
        "tp_pct": _arg("tp_pct", _def["tp_pct"], float),
        "trail_pct": _arg("trail_pct", _def["trail_pct"], float),
        "trail_arm_pct": _arg("trail_arm_pct", _def.get("trail_arm_pct", 1.5), float),
        "shoulder_min_high_pct": _arg("shoulder_min_high_pct", _def.get("shoulder_min_high_pct", 3.0), float),
        "shoulder_cut_pct": _arg("shoulder_cut_pct", _def.get("shoulder_cut_pct", 0.5), float),
        "time_start_hm": _arg("time_start_hm", _def["time_start_hm"], lambda v: int(float(v))),
        "time_end_hm": _arg("time_end_hm", _def["time_end_hm"], lambda v: int(float(v))),
        "max_daily": _arg("max_daily", _def["max_daily"], lambda v: int(float(v))),
        "cooldown_min": _arg("cooldown_min", _def["cooldown_min"], float),
        "max_daily_chg": _arg("max_daily_chg", _def["max_daily_chg"], float),
        "min_price": _arg("min_price", _def["min_price"], float),
        "high_chase_thr": _arg("high_chase_thr", _def.get("high_chase_thr", 0.96), float),
        "max_loss_krw": _arg("max_loss_krw", _def["max_loss_krw"], lambda v: int(float(v))),
        "slot_money": _arg("slot_money", _def["slot_money"], lambda v: int(float(v))),
        "max_hold_bars": _arg("max_hold_bars", _def.get("max_hold_bars", 0), lambda v: int(float(v))),
        "fee_rate_pct": _def.get("fee_rate_pct", 0.015),
        "sell_tax_pct": _def.get("sell_tax_pct", 0.18),
    }
    _uhf = request.args.get("use_high_chase_filter")
    if _uhf not in (None, ""):
        ui["use_high_chase_filter"] = str(_uhf).strip().lower() in ("1", "true", "y", "yes", "on")
    else:
        ui["use_high_chase_filter"] = bool(_def.get("use_high_chase_filter", True))
    engine = _rb_ui_to_engine_params(ui)

    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) < 5:
                continue
            codes_candles[code] = [dict(r) for r in rows]

        use_saved_history, rb_univ_mode, _ = _parse_backtest_universe_arg(
            request, default="history", sim_kind=None,
        )
        universe_by_slot, universe_source, universe_history_slots, _scan_iv = (
            _resolve_backtest_universe(
                db,
                start_key,
                end_key,
                use_saved_history,
                codes_candles,
                strategy_id="RANGE_BREAK",
            )
        )

        latest_env = db.get_latest_env()
        env_row = dict(latest_env["snapshot"]) if latest_env else {}
        fee_rate, sell_tax, slot_from_env = rbc.fee_and_slot_from_env(env_row)
        slot_money_v = float(ui.get("slot_money") or slot_from_env)
        max_stocks_req = _arg(
            "max_stocks", _def.get("max_stocks", 3), lambda v: int(float(v)),
        )
        total_budget_req = _arg(
            "total_budget_krw", _def.get("total_budget_krw", 0), float,
        )
        portfolio = rbc.resolve_range_break_portfolio_params(
            env_row,
            None,
            slot_money=slot_money_v,
            max_stocks=max_stocks_req if max_stocks_req > 0 else None,
            total_budget_krw=total_budget_req if total_budget_req > 0 else None,
        )
        total_budget_v = float(portfolio["total_budget_krw"])
        max_stocks_v = int(portfolio["max_stocks"])
        engine["slot_money"] = slot_money_v
        engine["max_stocks"] = max_stocks_v
        engine["total_budget_krw"] = total_budget_v
        engine["portfolio_mode"] = True

        bt_meta: Dict[str, Any] = {"db": db, "start_key": start_key, "end_key": end_key}
        all_trades = rbc.run_range_break_backtest_web_aligned(
            codes_candles,
            engine,
            universe_by_slot=universe_by_slot,
            slot_money=slot_money_v,
            fee_rate=fee_rate,
            sell_tax=sell_tax,
            max_stocks=max_stocks_v,
            total_budget_krw=total_budget_v,
            meta_out=bt_meta,
        )

        period_days = _backtest_period_days(start, end, fallback=1)
        stats = rbc.summarize_range_break_trades(
            all_trades,
            total_budget_krw=total_budget_v,
            period_days=period_days,
        )
        total = int(stats["total_trades"])
        total_pnl = int(stats["total_pnl"])
        wins_n = int(stats["wins"])
        losses_n = int(stats["losses"])
        avg_hold = float(stats["avg_hold_min"])
        pf = float(stats["pf"])
        bot_pct = float(stats["bot_pct"])
        daily_avg_pct = float(stats["daily_avg_pct"])

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

        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:
            rk = str(t.get("sell_reason") or "unknown")
            reasons[rk] = reasons.get(rk, 0) + 1

        trades_out = _trades_recent_first(all_trades, 200)
        _enrich_trades_with_names(db, trades_out)

        ts_hm = int(ui["time_start_hm"])
        te_hm = int(ui["time_end_hm"])
        return jsonify({
            "params": {
                **ui,
                "max_stocks": max_stocks_v,
                "total_budget_krw": total_budget_v,
                "start": start,
                "end": end,
                "time_window": f"{ts_hm:04d}-{te_hm:04d}",
                "codes_analyzed": len(codes),
                "universe_source": universe_source,
                "universe_history_slots": universe_history_slots,
                "universe": rb_univ_mode,
                "strategy_id": "RANGE_BREAK",
            },
            "summary": {
                "total_trades": total,
                "win_trades": wins_n,
                "loss_trades": losses_n,
                "win_rate": float(stats["win_rate"]),
                "total_pnl": total_pnl,
                "avg_hold_min": round(avg_hold, 1),
                "profit_factor": round(pf, 2),
                "max_drawdown": round(mdd),
                "bot_pct": bot_pct,
                "daily_avg_pct": daily_avg_pct,
                "backtest_days": period_days,
                "total_budget_krw": total_budget_v,
                "slot_money": slot_money_v,
                "max_stocks": max_stocks_v,
                "budget_warning": portfolio.get("budget_warning"),
                "backtest_buy_source": bt_meta.get("backtest_buy_source"),
            },
            "equity": equity,
            "daily": daily_list,
            "reasons": reasons,
            "trades": trades_out,
        })
    except Exception as e:
        logger.exception("range_break backtest failed")
        return jsonify({"error": str(e)}), 500
    finally:
        db.close()


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

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


def _updow_db() -> TradeDB:
    """UPDOW 탭·API: 분봉(holding_min_candles) + updow_stock_config."""
    db = _db()
    uhc.ensure_updow_backtest_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:
            try:
                body = request.get_json(force=True) or {}
                name = body.pop("name", "")
                hb.set_stock_config(db, code, name, body)
                return jsonify({"ok": True})
            except Exception as e:
                logger.exception("홀딩 설정 저장 실패 (%s)", code)
                return jsonify({"ok": False, "error": str(e)}), 500
    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)
        if "error" not in res:
            res["candle_rows"] = hb.build_daily_candle_display_rows(candles)
        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 = max(0, min(50, int(request.args.get("min_trades", 1))))
    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 = max(0, min(50, int(request.args.get("min_trades", 1))))

    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:
            return jsonify({"error": f"봉 부족: {len(candles)}개"}), 400

        # 추세BT와 동일 축: DB 최신 저장값 전체를 base로 쓰고, URL(카드 입력)만 덮어씀.
        # (예: buy1_ratio 가 URL에 없으면 예전엔 DEFAULT 50%로만 탐색되어 수량·손익이 추세BT의 2배로 나오는 문제)
        stored = hb.get_stock_config(db, code)
        base_cfg: Dict[str, float] = {}
        for key in hb.DEFAULT_STOCK_CONFIG.keys():
            raw = stored.get(key, hb.DEFAULT_STOCK_CONFIG[key])
            try:
                base_cfg[key] = float(raw)
            except (TypeError, ValueError):
                base_cfg[key] = float(hb.DEFAULT_STOCK_CONFIG[key])
        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

        results, meta = hb.run_param_search(
            candles, min_trades=min_trades, base_cfg=base_cfg
        )
        return jsonify({"code": code, "top": results[:30], "meta": meta})
    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) 로 분봉 수집 → holding_min_candles 저장 (tf 컬럼에 분 단위 저장).
    기본 tf=60. 본문 tf 로 1/3/5/10/15/30/45/60 지원 (키움 tic_scope 와 동일).

    필요 DB env_config 키: KIWOOM_APP_KEY, KIWOOM_APP_SECRET
    """
    body  = request.get_json(force=True, silent=True) or {}
    code  = body.get("code", "")
    market_type = str(body.get("market_type", "KR")).strip().upper() or "KR"
    exchange = str(body.get("exchange", "KRX")).strip().upper() or ("KRX" if market_type == "KR" else "NASD")
    symbol = str(body.get("symbol", code)).strip().upper() or code
    market_type, exchange, symbol = uhc.resolve_market_meta(code, market_type, exchange, symbol)
    start = body.get("start", "")
    end   = body.get("end", datetime.now().strftime("%Y-%m-%d"))
    try:
        tf = int(body.get("tf", 60))
    except (TypeError, ValueError):
        tf = 60
    if tf not in hb.KIWOOM_MINUTE_TICS:
        return jsonify({
            "error": f"tf는 키움 ka10080 허용값만 가능: {list(hb.KIWOOM_MINUTE_TICS)} (요청: {tf})",
        }), 400

    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,
        "market_type":  market_type,
        "exchange":     exchange,
        "symbol":       symbol,
        "tf":           tf,
        "source":       "kis_overseas" if market_type != "KR" else "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")

            kiwoom_key = ""
            kiwoom_secret = ""
            mode_label = "모의" if is_mock else "실전"

            if market_type == "KR":
                # KIS_MOCK 설정에 따라 키움 실전/모의 키 자동 선택
                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()
                else:
                    kiwoom_key    = str(r.get("KIWOOM_APP_KEY_REAL",    "") or "").strip()
                    kiwoom_secret = str(r.get("KIWOOM_APP_SECRET_REAL", "") or "").strip()
                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"키움 {tf}분봉 수집: {code} [{mode_label}/{market_type}:{exchange}] {start}~{end}"
                )
            else:
                _min_fetch_jobs[job_id]["source"] = "kis_overseas"
                logger.info(
                    f"KIS 해외 {tf}분봉 수집: {symbol} [{mode_label}/{market_type}:{exchange}] {start}~{end}"
                )
            rows = hb.fetch_60min_via_kiwoom(
                code,
                start,
                end,
                kiwoom_key,
                kiwoom_secret,
                is_mock=is_mock,
                tf_min=tf,
                market_type=market_type,
                exchange=exchange,
                symbol=symbol,
            )
            _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"키움 {tf}분봉 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"],
                            tf,
                            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"⚠️ 키움 {tf}분봉 일부 저장 실패 ({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"✅ 키움 {tf}분봉 저장 완료: {code} {saved}/{len(rows)}봉")
        except Exception as e:
            logger.error(f"❌ 키움 {tf}분봉 수집 오류 ({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()


# ═════════════════════════════════════════════════════════════════════════════
# UPDOWN 박스권 엔진 (신규) — kis_trader.engine.updown_box
#   판별 evaluate_box · 진입 box_entry_signal · 청산 eval_box_exit_at_price
#   백테 run_backtest_box · 파라서치 run_param_search_box_multi (실매 단일 소스)
#   유니버스: 조건검색 → 박스필터(SCAN) → updown_watchlist(sticky, 최대 30)
# ═════════════════════════════════════════════════════════════════════════════
def _box_cfg_with_overrides(args) -> Dict[str, Any]:
    """글로벌 박스 cfg(env UPDOWN_BOX_*) + 요청 쿼리 오버라이드."""
    from kis_trader.engine.updown_box import get_box_cfg_from_env

    cfg = dict(get_box_cfg_from_env())
    for k in list(cfg.keys()):
        v = args.get(k)
        if v is not None and str(v).strip() != "":
            if k == "ratchet_tiers":
                cfg[k] = str(v)  # 다단 래칫은 "수익%:컷%,…" 문자열 그대로
                continue
            try:
                cfg[k] = float(v)
            except (ValueError, TypeError):
                pass
    return cfg


@app.route("/api/updown_box/config", methods=["GET"])
def api_updown_box_config():
    """박스 글로벌 파라미터(env UPDOWN_BOX_*) + watchlist 통계."""
    from kis_trader.engine.updown_box import get_box_cfg_from_env
    from kis_trader.strategies.updown_watchlist import (
        active_count,
        ensure_updown_watchlist_table,
        watch_max,
    )
    from kis_trader.utils.env import get_env_int

    db = _db()
    try:
        cfg = get_box_cfg_from_env()
        ensure_updown_watchlist_table(db)
        return jsonify({
            "ok": True,
            "cfg": cfg,
            "watch_active": active_count(db),
            "watch_max": watch_max(db),
            "scan_tf_min": int(get_env_int("UPDOWN_SCAN_TF_MIN", 15)),
        })
    except Exception as e:
        return jsonify({"ok": False, "error": str(e)}), 500
    finally:
        db.close()


@app.route("/api/updown_box/watchlist", methods=["GET"])
def api_updown_box_watchlist():
    """updown_watchlist 전체(또는 active) 목록."""
    from kis_trader.strategies.updown_watchlist import (
        ensure_updown_watchlist_table,
        list_active_watchlist,
        list_all_watchlist,
    )

    only_active = str(request.args.get("active", "0")).strip() in ("1", "true", "on")
    db = _db()
    try:
        ensure_updown_watchlist_table(db)
        rows = list_active_watchlist(db) if only_active else list_all_watchlist(db)
        return jsonify({"ok": True, "rows": rows})
    except Exception as e:
        return jsonify({"ok": False, "error": str(e)}), 500
    finally:
        db.close()


@app.route("/api/updown_box/watchlist/add", methods=["POST"])
def api_updown_box_watchlist_add():
    """watchlist 수동 추가 (HTS 안 켜고 박스 후보 직접 등록)."""
    from kis_trader.strategies.updown_watchlist import (
        add_manual,
        ensure_updown_watchlist_table,
    )

    body = request.get_json(force=True, silent=True) or {}
    code = str(body.get("code", "")).strip()
    if len(code) != 6 or not code.isdigit():
        return jsonify({"ok": False, "error": "6자리 종목코드 필요"}), 400
    name = str(body.get("name", "")).strip()
    try:
        box_low = float(body.get("box_low") or 0)
        box_high = float(body.get("box_high") or 0)
    except (ValueError, TypeError):
        box_low = box_high = 0.0
    db = _db()
    try:
        ensure_updown_watchlist_table(db)
        ok = add_manual(db, code, name, box_low=box_low, box_high=box_high)
        return jsonify({"ok": bool(ok)})
    except Exception as e:
        return jsonify({"ok": False, "error": str(e)}), 500
    finally:
        db.close()


@app.route("/api/updown_box/watchlist/remove", methods=["POST"])
def api_updown_box_watchlist_remove():
    """watchlist 1건 삭제 (수동 제거)."""
    from kis_trader.strategies.updown_watchlist import (
        ensure_updown_watchlist_table,
        remove_manual,
    )

    body = request.get_json(force=True, silent=True) or {}
    code = str(body.get("code", "")).strip()
    if not code:
        return jsonify({"ok": False, "error": "code 필수"}), 400
    db = _db()
    try:
        ensure_updown_watchlist_table(db)
        ok = remove_manual(db, code)
        return jsonify({"ok": bool(ok)})
    except Exception as e:
        return jsonify({"ok": False, "error": str(e)}), 500
    finally:
        db.close()


@app.route("/api/updown_box/backtest", methods=["GET"])
def api_updown_box_backtest():
    """단일 종목 박스권 백테 (run_backtest_box) — 실매와 동일 엔진."""
    from kis_trader.engine.updown_box import evaluate_box, run_backtest_box
    from kis_trader.utils.env import get_env_int

    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

    tf_raw = request.args.get("tf")
    try:
        tf = int(float(tf_raw)) if tf_raw not in (None, "") else get_env_int("UPDOWN_SCAN_TF_MIN", 15)
    except (TypeError, ValueError):
        tf = get_env_int("UPDOWN_SCAN_TF_MIN", 15)
    if tf not in hb.KIWOOM_MINUTE_TICS:
        return jsonify({"error": f"tf는 {list(hb.KIWOOM_MINUTE_TICS)} 중 하나 (요청: {tf})"}), 400

    db = _updow_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 = "📥 먼저 [키움 분봉 수집]으로 해당 tf 데이터를 수집하세요."
            else:
                hint = f"DB에 {stats['count']}봉 있음 ({stats['min']} ~ {stats['max']})\n👉 시작일을 '{stats['min'][:10]}' 이후로 설정하세요."
            return jsonify({"error": f"{tf}분봉 없음\n{hint}"}), 400

        cfg = _box_cfg_with_overrides(request.args)
        min_need = int(cfg.get("min_bars", 20)) + 2
        if len(candles) < min_need:
            return jsonify({"error": f"봉 부족: {len(candles)}개 (최소 {min_need})"}), 400

        result = run_backtest_box(candles, cfg)
        # 현재(최근 봉 기준) 박스 판별 상태도 함께 (직관용)
        box_now = evaluate_box(candles, cfg)
        result["code"] = code
        result["tf"] = tf
        result["candle_count"] = len(candles)
        result["box_now"] = {
            "is_box": box_now.get("is_box"),
            "box_low": box_now.get("box_low"),
            "box_high": box_now.get("box_high"),
            "range_pct": box_now.get("range_pct"),
            "bb_bw": box_now.get("bb_bw"),
            "ma_slope": box_now.get("ma_slope"),
            "box_score": box_now.get("box_score"),
            "reason": box_now.get("reason"),
        }
        result["params"] = {k: cfg.get(k) for k in cfg}
        result["candle_rows"] = hb.build_daily_candle_display_rows(candles)
        return jsonify(result)
    finally:
        db.close()


@app.route("/api/updown_box/param_search", methods=["GET"])
def api_updown_box_param_search():
    """박스권 글로벌 파라미터 그리드 탐색 — 다종목 합산(run_param_search_box_multi).

    종목 소스: ?codes=005930,000660 또는 ?from_watchlist=1 (active) 또는 ?code=단일.
    """
    from kis_trader.engine.updown_box import (
        default_box_param_grid,
        get_box_cfg_from_env,
        run_backtest_box,
        run_param_search_box_multi,
    )
    from kis_trader.utils.env import get_env_int

    start_date = request.args.get("start", "")
    end_date = request.args.get("end", datetime.now().strftime("%Y-%m-%d"))
    min_trades = max(0, min(100, int(request.args.get("min_trades", 3))))
    mode = str(request.args.get("mode", "fast") or "fast").strip().lower()
    rank_by = str(request.args.get("rank_by", "alpha") or "alpha").strip().lower()
    keep_neg = str(request.args.get("keep_negative_alpha", "0")).strip() in ("1", "true", "on")

    tf_raw = request.args.get("tf")
    try:
        tf = int(float(tf_raw)) if tf_raw not in (None, "") else get_env_int("UPDOWN_SCAN_TF_MIN", 15)
    except (TypeError, ValueError):
        tf = get_env_int("UPDOWN_SCAN_TF_MIN", 15)
    if tf not in hb.KIWOOM_MINUTE_TICS:
        return jsonify({"error": f"tf는 {list(hb.KIWOOM_MINUTE_TICS)} 중 하나 (요청: {tf})"}), 400

    db = _updow_db()
    try:
        codes: List[str] = []
        if str(request.args.get("from_watchlist", "0")).strip() in ("1", "true", "on"):
            from kis_trader.strategies.updown_watchlist import (
                ensure_updown_watchlist_table,
                list_active_watchlist,
            )
            ensure_updown_watchlist_table(db)
            codes = [str(r.get("code") or "").strip() for r in list_active_watchlist(db)]
        else:
            raw = request.args.get("codes") or request.args.get("code") or ""
            codes = [c for c in str(raw).replace(" ", "").split(",") if c]
        codes = [c for c in codes if len(c) == 6 and c.isdigit()]
        if not codes:
            return jsonify({"error": "종목 없음 — codes=005930,000660 또는 from_watchlist=1"}), 400

        base_cfg = get_box_cfg_from_env()
        min_bars = int(base_cfg.get("min_bars", 20)) + 2
        candles_by_code: Dict[str, List[Dict[str, Any]]] = {}
        skipped: List[str] = []
        for code in codes:
            cs = hb.get_stored_min_candles(db, code, start_date, end_date, tf_min=tf)
            if cs and len(cs) >= min_bars:
                candles_by_code[code] = cs
            else:
                skipped.append(code)
        if not candles_by_code:
            return jsonify({"error": f"{tf}분봉 데이터 가진 종목 없음 (수집 필요)"}), 400

        # === 추세종목(α≤0) 자동 제외 — base_cfg 기준 봇손익 ≤ B&H 이면 박스 대상 아님 ===
        dropped_trend: List[Dict[str, Any]] = []
        if not keep_neg:
            kept: Dict[str, List[Dict[str, Any]]] = {}
            for code, cs in candles_by_code.items():
                bt = run_backtest_box(cs, base_cfg)
                a = float(bt.get("alpha_pct", bt.get("total_pnl", 0)) or 0)
                if a > 0:
                    kept[code] = cs
                else:
                    dropped_trend.append({
                        "code": code,
                        "bot_pnl": float(bt.get("total_pnl", 0) or 0),
                        "bh_pct": float(bt.get("buy_hold_pct", 0) or 0),
                        "alpha": round(a, 3),
                    })
            if not kept:
                return jsonify({
                    "error": "α>0 종목 0 — 박스전략 적합 종목이 없습니다 (모두 추세주). "
                             "keep_negative_alpha=1 로 강제 가능",
                    "dropped_trend": dropped_trend,
                }), 400
            candles_by_code = kept

        grid = default_box_param_grid("full" if mode == "full" else "fast")
        try:
            combo_cap = int(float(get_env_int("UPDOWN_BOX_WEB_MAX_COMBOS", 500)))
        except (TypeError, ValueError):
            combo_cap = 500
        results, meta = run_param_search_box_multi(
            candles_by_code,
            grid=grid,
            base_cfg=base_cfg,
            min_trades_total=min_trades,
            max_combos=max(0, combo_cap),
            rank_by=rank_by,
        )
        meta["tf"] = tf
        meta["search_mode"] = mode
        meta["skipped_codes"] = skipped
        meta["dropped_trend"] = dropped_trend
        top = [{**r, "tf": tf} for r in results[:30]]
        return jsonify({"codes": list(candles_by_code.keys()), "top": top, "meta": meta})
    finally:
        db.close()


@app.route("/api/updown_box/apply", methods=["POST"])
def api_updown_box_apply():
    """파라서치 결과(apply_cfg) → env_config UPDOWN_BOX_* 적용."""
    from kis_trader.engine.updown_box import box_cfg_to_env_patch

    body = request.get_json(force=True, silent=True) or {}
    apply_cfg = body.get("apply_cfg") or {}
    if not isinstance(apply_cfg, dict) or not apply_cfg:
        return jsonify({"ok": False, "error": "apply_cfg 필요"}), 400
    clean: Dict[str, Any] = {}
    for k, v in apply_cfg.items():
        if k == "ratchet_tiers":
            clean[k] = str(v)  # 다단 래칫 문자열 그대로
        elif isinstance(v, (int, float)) or str(v).replace(".", "", 1).isdigit():
            clean[k] = float(v)
    patch = box_cfg_to_env_patch(clean)
    if not patch:
        return jsonify({"ok": False, "error": "적용할 키 없음"}), 400
    db = _db()
    try:
        snap = db.get_merged_env_snapshot() or {}
        for k, v in patch.items():
            snap[k] = str(v)
        eid = db.insert_env_snapshot(snap)
        if eid is None:
            return jsonify({"ok": False, "error": "insert_env_snapshot 실패"}), 500
        return jsonify({"ok": True, "env_id": eid, "applied": patch})
    except Exception as e:
        return jsonify({"ok": False, "error": str(e)}), 500
    finally:
        db.close()


@app.route("/api/updown_box/stock_list", methods=["GET"])
def api_updown_box_stock_list():
    """종목 셀렉트용 목록 — watchlist(active+inactive) + 종목별 오버라이드 등록 종목 합집합."""
    from kis_trader.strategies.updown_box_stock_cfg import list_box_stock_cfg
    from kis_trader.strategies.updown_watchlist import (
        ensure_updown_watchlist_table,
        list_all_watchlist,
    )

    db = _updow_db()
    try:
        ensure_updown_watchlist_table(db)
        merged: Dict[str, Dict[str, Any]] = {}
        for r in list_all_watchlist(db):
            c = str(r.get("code") or "").strip()
            if not c:
                continue
            merged[c] = {"code": c, "name": str(r.get("name") or c), "n_overrides": 0,
                         "status": str(r.get("status") or "")}
        for it in list_box_stock_cfg(db):
            c = str(it.get("code") or "").strip()
            if not c:
                continue
            if c in merged:
                merged[c]["n_overrides"] = int(it.get("n_overrides") or 0)
            else:
                merged[c] = {"code": c, "name": str(it.get("name") or c),
                             "n_overrides": int(it.get("n_overrides") or 0), "status": ""}
        items = sorted(merged.values(), key=lambda x: x["code"])
        return jsonify({"items": items})
    finally:
        db.close()


@app.route("/api/updown_box/stock_cfg", methods=["GET"])
def api_updown_box_stock_cfg_get():
    """종목별 박스 오버라이드 + 현재 글로벌값 — 종목 선택 시 인풋 채우기용.

    응답: {code, name, overrides:{설정된 키만}, global:{박스 전 키 기본/글로벌}, effective:{병합 결과}}
    """
    from kis_trader.engine.updown_box import get_box_cfg_from_env
    from kis_trader.strategies.updown_box_stock_cfg import (
        BOX_ALL_KEYS,
        get_box_overrides,
    )

    code = str(request.args.get("code") or "").strip()
    if not code:
        return jsonify({"error": "code 필수"}), 400
    db = _updow_db()
    try:
        glob = get_box_cfg_from_env()
        ov = get_box_overrides(db, code)
        eff: Dict[str, Any] = {}
        for k in BOX_ALL_KEYS:
            eff[k] = ov[k] if k in ov else glob.get(k)
        glob_out = {k: glob.get(k) for k in BOX_ALL_KEYS}
        return jsonify({"code": code, "overrides": ov, "global": glob_out, "effective": eff})
    finally:
        db.close()


@app.route("/api/updown_box/stock_cfg", methods=["POST"])
def api_updown_box_stock_cfg_save():
    """종목별 박스 오버라이드 저장(upsert). body: {code, name, overrides:{키:값}}.

    값이 빈칸/None 이면 해당 키는 글로벌 상속(NULL)로 클리어. ratchet_tiers='' 는 래칫 OFF.
    """
    from kis_trader.strategies.updown_box_stock_cfg import (
        BOX_ALL_KEYS,
        delete_box_overrides,
        set_box_overrides,
    )

    body = request.get_json(force=True, silent=True) or {}
    code = str(body.get("code") or "").strip()
    if not code:
        return jsonify({"ok": False, "error": "code 필수"}), 400
    name = str(body.get("name") or code)
    raw_ov = body.get("overrides") or {}
    if not isinstance(raw_ov, dict):
        return jsonify({"ok": False, "error": "overrides dict 필요"}), 400

    db = _updow_db()
    try:
        if str(body.get("clear") or "").strip() in ("1", "true", "on"):
            ok = delete_box_overrides(db, code)
            return jsonify({"ok": ok, "cleared": True})
        # 허용 키만 통과
        ov = {k: raw_ov[k] for k in BOX_ALL_KEYS if k in raw_ov}
        ok = set_box_overrides(db, code, name, ov)
        if not ok:
            return jsonify({"ok": False, "error": "저장 실패"}), 500
        return jsonify({"ok": True, "code": code, "saved_keys": list(ov.keys())})
    finally:
        db.close()


@app.route("/api/permanent_subs", methods=["GET"])
def api_permanent_subs_list():
    """영구구독(국내 WS + 해외 WS) 목록. 해외(US)는 ws_tr_key(D+거래소+심볼) 포함."""
    import permanent_subs as ps

    db = _db()
    try:
        ps.ensure_permanent_subs_table(db)
        rows = ps.list_permanent_subs(db, enabled_only=False)
        out = []
        for r in rows:
            mt = str(r.get("market_type") or "KR").strip().upper()
            ex = str(r.get("exchange") or "").strip().upper()
            sym = str(r.get("symbol") or r.get("code") or "").strip().upper()
            # 해외만 실시간 WS tr_key 표기, 국내는 종목코드 자체가 키
            ws_tr_key = ps.us_ws_tr_key(ex, sym) if mt == "US" else str(r.get("code") or "")
            out.append({
                "code": r.get("code"),
                "market_type": mt,
                "exchange": ex,
                "symbol": sym,
                "tf_min": r.get("tf_min"),
                "enabled": int(r.get("enabled", 1)),
                "note": r.get("note") or "",
                "ws_tr_key": ws_tr_key,
            })
        return jsonify({"ok": True, "rows": out})
    except Exception as e:
        return jsonify({"ok": False, "error": str(e), "rows": []}), 500
    finally:
        db.close()


@app.route("/api/permanent_subs/save", methods=["POST"])
def api_permanent_subs_save():
    """영구구독 1건 등록/수정 (code UNIQUE upsert)."""
    import permanent_subs as ps

    body = request.get_json(force=True, silent=True) or {}
    code = str(body.get("code") or "").strip().upper()
    if not code:
        return jsonify({"ok": False, "error": "code 필수"}), 400
    try:
        tf_min = int(float(body.get("tf_min") or 15))
    except (TypeError, ValueError):
        tf_min = 15
    db = _db()
    try:
        ps.upsert_permanent_sub(
            db,
            code,
            market_type=body.get("market_type"),
            exchange=body.get("exchange"),
            symbol=body.get("symbol"),
            tf_min=tf_min,
            enabled=bool(body.get("enabled", True)),
            note=str(body.get("note") or ""),
        )
        return jsonify({"ok": True, "code": code})
    except Exception as e:
        return jsonify({"ok": False, "error": str(e)}), 500
    finally:
        db.close()


@app.route("/api/permanent_subs/delete", methods=["POST"])
def api_permanent_subs_delete():
    """영구구독 1건 삭제."""
    import permanent_subs as ps

    body = request.get_json(force=True, silent=True) or {}
    code = str(body.get("code") or "").strip().upper()
    if not code:
        return jsonify({"ok": False, "error": "code 필수"}), 400
    db = _db()
    try:
        ok = ps.remove_permanent_sub(db, code)
        return jsonify({"ok": bool(ok), "code": code})
    except Exception as e:
        return jsonify({"ok": False, "error": str(e)}), 500
    finally:
        db.close()


# ─────────────────────────────────────────────────────────────────────────────
# 웹 파라미터 탐색 (SCALP / MOMENTUM / BREAKOUT / SHORT)
# ─────────────────────────────────────────────────────────────────────────────
# 브라우저·프록시 타임아웃 방지: 대형 그리드 탐색은 CLI 만 사용.
#   kis_trader/backtest/param_search_scalping.py
#   kis_trader/backtest/param_search_momentum.py
#   kis_trader/backtest/param_search_breakout.py
#   kis_trader/backtest/tail_param_search.py
# 웹: /api/env/params → 입력란에 DB 최신값 → 백테스트 API 만 호출.
# UPDOWN 박스: /api/updown_box/param_search (글로벌 그리드, 다종목 합산). 홀딩: /api/holding/*/param_search.
# DBBAND: /api/dbband/param_search (종목별 dbband_stock_config + env 그리드)


def _dbband_db() -> TradeDB:
    from kis_trader.strategies import dbband_stock_cfg as dsc
    db = _db()
    dsc.ensure_dbband_backtest_tables(db)
    return db


def _dbband_merged_engine_tf(db_hold: TradeDB, code: str) -> Tuple[Dict[str, Any], int, bool]:
    from kis_trader.strategies import dbband_stock_cfg as dsc
    dsc.ensure_dbband_stock_config_table(db_hold)
    base = bbe.get_dbband_defaults_from_db(db_hold) if _DBBAND_ENGINE_AVAILABLE else {}
    snap = db_hold.get_merged_env_snapshot()
    env_tf = int(base.get("timeframe") or snap.get("DBBAND_TIMEFRAME") or 15)
    has_row = dsc.get_dbband_stock_config_row(db_hold, code) is not None
    merged = dsc.load_dbband_engine_cfg(db_hold, code, base)
    tf = dsc.effective_dbband_tf_for_code(db_hold, code, env_tf)
    return merged, tf, has_row


@app.route("/api/dbband/stocks", methods=["GET"])
def api_dbband_stocks():
    from kis_trader.strategies import dbband_stock_cfg as dsc
    db = _dbband_db()
    try:
        return jsonify(dsc.list_dbband_stock_codes(db))
    finally:
        db.close()


@app.route("/api/dbband/config", methods=["GET"])
def api_dbband_config():
    from kis_trader.strategies import dbband_stock_cfg as dsc
    code = (request.args.get("code") or "").strip()
    db = _dbband_db()
    try:
        base = bbe.get_dbband_defaults_from_db(db) if _DBBAND_ENGINE_AVAILABLE else {}
        if not code:
            return jsonify({
                "env_fallback": dsc.engine_cfg_to_ui(base),
                "dbband_tf_min": int(base.get("timeframe") or 15),
            })
        merged, tf_min, has_row = _dbband_merged_engine_tf(db, code)
        meta = dsc.get_dbband_stock_meta(db, code) or {}
        return jsonify({
            "code": code,
            "name": meta.get("name") or code,
            "dbband_tf_min": tf_min,
            "dbband_stock_saved": has_row,
            "param_source": "dbband_stock_config" if has_row else "env-fallback",
            "engine": dsc.engine_cfg_to_ui(merged),
            "market_type": meta.get("market_type"),
            "exchange": meta.get("exchange"),
            "symbol": meta.get("symbol") or code,
        })
    finally:
        db.close()


@app.route("/api/dbband/save_holding", methods=["POST"])
def api_dbband_save_holding():
    """선택 종목 ``dbband_stock_config`` INSERT — 웹·백테·실매 단일 소스."""
    from kis_trader.strategies import dbband_stock_cfg as dsc
    body = request.get_json(force=True, silent=True) or {}
    code = str(body.get("code", "")).strip()
    if not code:
        return jsonify({"error": "code 필수"}), 400
    market_type = str(body.get("market_type", "KR")).strip().upper() or "KR"
    exchange = str(body.get("exchange", "KRX")).strip().upper() or ("KRX" if market_type == "KR" else "NASD")
    symbol = str(body.get("symbol", code)).strip().upper() or code
    name = str(body.get("name", "")).strip()
    src = body.get("apply_cfg") if isinstance(body.get("apply_cfg"), dict) else body
    tf_raw = body.get("tf_min", body.get("tf"))
    try:
        tf_min = int(float(tf_raw)) if tf_raw is not None and str(tf_raw).strip() != "" else None
    except (TypeError, ValueError):
        tf_min = None
    db = _dbband_db()
    try:
        _, hold_tf, _ = _dbband_merged_engine_tf(db, code)
        if tf_min is None:
            tf_min = int(hold_tf)
        if tf_min not in hb.KIWOOM_MINUTE_TICS:
            return jsonify({"error": f"tf는 {list(hb.KIWOOM_MINUTE_TICS)} 중 하나 (요청: {tf_min})"}), 400
        nm = name or code
        meta = dsc.get_dbband_stock_meta(db, code)
        if not name and meta and meta.get("name"):
            nm = str(meta["name"]).strip()
        dsc.set_dbband_stock_config(
            db, code, nm, src, tf_min=tf_min,
            market_type=market_type, exchange=exchange, symbol=symbol,
        )
        return jsonify({"ok": True, "code": code, "symbol": symbol, "dbband_tf_min": int(tf_min)})
    except Exception as e:
        logger.error("DBBAND 종목 저장 오류: %s", e)
        return jsonify({"error": str(e)}), 500
    finally:
        db.close()


@app.route("/api/dbband/backtest", methods=["GET"])
def api_dbband_backtest():
    """종목 1개 더블 BB 백테 — holding_min_candles + dbband_stock_config."""
    if not _DBBAND_ENGINE_AVAILABLE:
        return jsonify({"error": "dbband_engine 미설치"}), 503
    code = request.args.get("code", "").strip()
    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 필수 (QQQM·069500 등 종목별 파라미터)"}), 400

    db = _dbband_db()
    try:
        merged, hold_tf, holding_saved = _dbband_merged_engine_tf(db, code)
        tf_raw = request.args.get("tf")
        tf = int(float(tf_raw)) if tf_raw not in (None, "") else int(hold_tf)
        if tf not in hb.KIWOOM_MINUTE_TICS:
            return jsonify({"error": f"tf는 {list(hb.KIWOOM_MINUTE_TICS)} 중 하나"}), 400

        cfg = dict(merged)
        for ck in bbe.CFG_ENGINE_KEYS:
            v = request.args.get(ck)
            if v is None:
                continue
            if ck in ("side_mode", "entry_mode", "stop_mode", "tp_mode", "exit_mode"):
                cfg[ck] = str(v).strip().lower()
            elif ck == "use_trend_filter":
                cfg[ck] = str(v).lower() in ("1", "true", "y", "yes", "on")
            else:
                try:
                    cfg[ck] = float(v)
                except (TypeError, ValueError):
                    pass
        if request.args.get("sl_pct") not in (None, ""):
            cfg["sl_pct"] = abs(float(request.args.get("sl_pct"))) / 100.0
        if request.args.get("tp_pct") not in (None, ""):
            cfg["tp_pct"] = abs(float(request.args.get("tp_pct"))) / 100.0
        if request.args.get("shoulder_min_high") not in (None, ""):
            cfg["shoulder_min_high"] = abs(float(request.args.get("shoulder_min_high"))) / 100.0
        if request.args.get("shoulder_cut_pct") not in (None, ""):
            cfg["shoulder_cut_pct"] = abs(float(request.args.get("shoulder_cut_pct"))) / 100.0

        raw_candles = hb.get_stored_min_candles(db, code, start_date, end_date, tf_min=tf)
        candles = dbbc.normalize_stored_min_candles(raw_candles)
        if not candles:
            stats = hb.get_min_candle_stats(db, code, tf_min=tf)
            hint = "📥 [키움 분봉 수집] 또는 영구구독 WS로 분봉을 먼저 수집하세요."
            if stats.get("count"):
                hint = f"DB {stats['count']}봉 ({stats['min']}~{stats['max']}) — 시작일 조정"
            return jsonify({"error": f"{tf}분봉 없음\n{hint}"}), 400

        min_need = max(int(cfg.get("trend_ma_period") or 200) + 10, 50)
        if len(candles) < min_need:
            return jsonify({"error": f"봉 부족: {len(candles)} < {min_need} (추세MA+워밍업)"}), 400

        trades = bbe.run_dbband_backtest_single(candles, cfg)
        snap = db.get_merged_env_snapshot()
        portfolio = dbbc.resolve_dbband_portfolio_params(snap, cfg)
        slot_money = float(cfg.get("slot_money") or portfolio.get("slot_money") or 3_000_000)
        fee_rate, sell_tax, _ = dbbc.fee_and_slot_from_env_row(snap)
        from kis_trader.backtest.backtest_portfolio_common import backtest_slip_pct
        dbbc.attach_dbband_trade_pnl(
            trades, slot_money=slot_money, fee_rate=fee_rate, sell_tax=sell_tax,
            slip_pct=backtest_slip_pct(cfg),
        )

        from kis_trader.strategies import dbband_stock_cfg as dsc
        meta = dsc.get_dbband_stock_meta(db, code) or {}
        nm = str(meta.get("name") or code).strip() or code
        ps = "dbband_stock_config" if holding_saved else "env-fallback"
        report = dbbc.build_dbband_backtest_report(
            trades,
            candles,
            cfg,
            code=code,
            name=nm,
            start_date=start_date,
            end_date=end_date,
            tf=tf,
            candle_count=len(candles),
            param_source=ps,
            portfolio=portfolio,
        )

        return jsonify({
            "ok": True,
            "code": code,
            "tf": tf,
            "candle_count": len(candles),
            "param_source": ps,
            "params": report["params"],
            "summary": report["summary"],
            "equity": report["equity"],
            "daily": report["daily"],
            "reasons": report["reasons"],
            "trades": trades,
        })
    except Exception as e:
        logger.error("DBBAND 백테 오류: %s", e)
        return jsonify({"error": str(e)}), 500
    finally:
        db.close()


@app.route("/api/dbband/param_search", methods=["GET"])
def api_dbband_param_search():
    """종목별 그리드 탐색 — ``dbband_stock_config`` 베이스 + env ``DBBAND_GRID_*``."""
    if not _DBBAND_ENGINE_AVAILABLE:
        return jsonify({"error": "dbband_engine 미설치"}), 503
    code = request.args.get("code", "").strip()
    if not code:
        return jsonify({"error": "code 필수"}), 400
    start_date = request.args.get("start", "")
    end_date = request.args.get("end", datetime.now().strftime("%Y-%m-%d"))
    db = _dbband_db()
    try:
        from kis_trader.backtest.dbband_param_search import run_search_for_code
        merged, hold_tf, holding_saved = _dbband_merged_engine_tf(db, code)
        tf = int(request.args.get("tf") or hold_tf)
        top_n = max(1, min(100, int(request.args.get("top", 30))))
        out = run_search_for_code(
            db, code, start_date, end_date, tf, base_cfg=merged, top_n=top_n,
        )
        out["param_source"] = "dbband_stock_config" if holding_saved else "env-fallback"
        return jsonify(out)
    except Exception as e:
        logger.error("DBBAND 파라서치 오류: %s", e)
        return jsonify({"error": str(e)}), 500
    finally:
        db.close()


def _pf_parse_amt(v: Any) -> float:
    """KIS 금액 문자열 → float (쉼표 제거)."""
    try:
        return abs(float(str(v or 0).replace(",", "")))
    except (TypeError, ValueError):
        return 0.0


def _pf_int_price(v: Any) -> int:
    """주식 가격·평가금 — 원 단위 정수 (소수점 제거)."""
    try:
        return int(round(float(v or 0)))
    except (TypeError, ValueError):
        return 0


def _portfolio_account_summary(order_mgr) -> Dict[str, Any]:
    """실계좌 대조 시 상단 요약 — 입금액(env) · 예수금 · 주식평가금 · 총자산."""
    from kis_trader.utils.env import get_env_float

    td = float(get_env_float("TOTAL_DEPOSIT", 0) or 0)
    out: Dict[str, Any] = {
        "total_deposit": _pf_int_price(td) if td > 0 else None,
        "cash": None,
        "holdings_eval": None,
        "total_asset": None,
    }
    try:
        balance = order_mgr.client.get_account_balance()
        if not balance:
            return out
        out2 = balance.get("output2") or {}
        if isinstance(out2, list) and out2:
            out2 = out2[0]
        elif not isinstance(out2, dict):
            out2 = {}
        dnca = _pf_parse_amt(out2.get("dnca_tot_amt"))
        tot_evlu = _pf_parse_amt(out2.get("tot_evlu_amt"))
        holdings_eval = 0.0
        out1 = balance.get("output1") or []
        if isinstance(out1, dict):
            out1 = [out1]
        for it in out1:
            qty = _pf_parse_amt(it.get("hldg_qty") or it.get("HLDG_QTY"))
            if qty <= 0:
                continue
            evlu = _pf_parse_amt(it.get("evlu_amt") or it.get("EVLU_AMT"))
            if evlu > 0:
                holdings_eval += evlu
            else:
                pr = _pf_parse_amt(it.get("prpr") or it.get("PRPR"))
                holdings_eval += pr * qty
        if tot_evlu <= 0 and (dnca > 0 or holdings_eval > 0):
            tot_evlu = dnca + holdings_eval
        out["cash"] = _pf_int_price(dnca) if dnca > 0 else None
        out["holdings_eval"] = _pf_int_price(holdings_eval) if holdings_eval > 0 else None
        out["total_asset"] = _pf_int_price(tot_evlu) if tot_evlu > 0 else None
    except Exception as e:
        logger.warning("portfolio account summary 실패: %s", e)
    return out


def _portfolio_item_from_row(
    row: Dict[str, Any],
    br: Dict[str, Any],
    *,
    with_broker: bool,
) -> Dict[str, Any]:
    code = str(row.get("code") or "")
    strat = str(row.get("strategy") or "")
    db_qty = int(row.get("current_qty") or 0)
    buy_px = float(row.get("avg_buy_price") or 0)
    br_avg = float(br.get("avg_price") or 0) if with_broker else 0.0
    cur_px = _portfolio_price_fast(row, br)
    broker_qty = int(br.get("qty") or 0) if with_broker else None
    ref_buy = br_avg if (with_broker and br_avg > 0) else buy_px
    pnl_pct = ((cur_px - ref_buy) / ref_buy * 100.0) if ref_buy > 0 and cur_px > 0 else 0.0
    eval_amt = cur_px * (broker_qty if with_broker and broker_qty else db_qty)
    if with_broker and broker_qty is not None:
        sync_ok = broker_qty >= db_qty if db_qty > 0 else broker_qty <= 0
    else:
        sync_ok = True
    return {
        "code": code,
        "name": row.get("name") or br.get("name") or code,
        "strategy": strat,
        "untracked": False,
        "can_sell": True,
        "db_qty": db_qty,
        "broker_qty": broker_qty,
        "buy_price": _pf_int_price(buy_px),
        "broker_avg_price": _pf_int_price(br_avg) if (with_broker and br_avg > 0) else None,
        "current_price": _pf_int_price(cur_px),
        "pnl_pct": round(pnl_pct, 2),
        "eval_amt": _pf_int_price(eval_amt) if eval_amt else 0,
        "buy_date": row.get("buy_date"),
        "sync_ok": sync_ok,
        "sync_note": (
            ""
            if sync_ok
            else f"실계좌 {broker_qty}주 vs DB {db_qty}주 — 수량 불일치(HTS 수동매매·동기화 필요)"
        ),
    }


@app.route("/api/portfolio/active", methods=["GET"])
def api_portfolio_active():
    """
    봇 DB(active_trades) + (broker=1) 실계좌 잔고 전체 — HTS 실시간잔고와 동일하게 표시.
    HTS 에서 팔기 전에 여기서 매도하면 OrderManager → close_trade 로 DB 동기화.
    """
    strategy = (request.args.get("strategy") or "ALL").strip()
    with_broker = str(request.args.get("broker", "0")).lower() in ("1", "true", "yes")
    order_mgr, _mc = _portfolio_infra()
    broker: Dict[str, Dict] = {}
    if with_broker:
        broker = order_mgr.get_broker_holdings(force=False)
    rows = _list_active_trades_rows(
        None if strategy.upper() == "ALL" else strategy,
        for_portfolio=True,
    )
    # 미등록 종목 origin 분류용 — 봇이 주문한 적 있는 코드(orders BUY) + 수동 보호목록
    bot_bought_codes, manual_hold_codes = _portfolio_origin_sets()
    # 홀딩봇(HOLDING) 등 이 탭에서 제외된 전략이 active_trades 로 관리 중인 코드.
    #   이 탭 목록에선 빠지지만 실계좌엔 있어 untracked 로 보이므로 '봇고아' 오분류 방지.
    excluded_strategy_codes: set = set()
    try:
        _ex_sql = ", ".join(["%s"] * len(PORTFOLIO_EXCLUDED_STRATEGY_IDS))
        _cur = _db().conn.execute(
            f"SELECT DISTINCT code FROM active_trades WHERE strategy IN ({_ex_sql})",
            tuple(PORTFOLIO_EXCLUDED_STRATEGY_IDS),
        )
        for _r in (_cur.fetchall() or []):
            _c = str(_r.get("code") or "").strip()
            if _c:
                excluded_strategy_codes.add(_c)
    except Exception as e:
        logger.warning("제외 전략 보유코드 조회 실패: %s", e)

    items: List[Dict[str, Any]] = []
    if with_broker:
        active_by_code: Dict[str, List[Dict[str, Any]]] = {}
        for row in rows:
            c = str(row.get("code") or "")
            active_by_code.setdefault(c, []).append(row)
        all_codes = sorted(set(broker.keys()) | set(active_by_code.keys()))
        for code in all_codes:
            br = broker.get(code) or {}
            at_rows = active_by_code.get(code) or []
            if at_rows:
                for row in at_rows:
                    items.append(_portfolio_item_from_row(row, br, with_broker=True))
            else:
                bqty = int(br.get("qty") or 0)
                if bqty <= 0:
                    continue
                br_avg = float(br.get("avg_price") or 0)
                cur_px = _portfolio_price_fast({}, br)
                pnl_pct = (
                    ((cur_px - br_avg) / br_avg * 100.0) if br_avg > 0 and cur_px > 0 else 0.0
                )
                # origin 분류: 홀딩봇 관리분 최우선(이 탭 제외 전략) → 수동보호목록
                #   → orders BUY 기록 있으면 봇 고아 → 없으면 수동매수 추정
                if code in excluded_strategy_codes:
                    origin, origin_note = "holding", "홀딩봇(HOLDING) 관리 — 별도 봇, 여기서 매도 안 함"
                elif code in manual_hold_codes:
                    origin, origin_note = "manual", "수동 보호목록(MANUAL_HOLD_CODES)"
                elif code in bot_bought_codes:
                    origin, origin_note = "bot", "봇 주문기록 있음 — 체결됐으나 active_trades 미기록(고아)"
                else:
                    origin, origin_note = "manual", "봇 주문기록 없음 — 수동매수 추정(보호)"
                items.append({
                    "code": code,
                    "name": br.get("name") or code,
                    "strategy": "",
                    "untracked": True,
                    "origin": origin,
                    "can_sell": False,
                    "db_qty": 0,
                    "broker_qty": bqty,
                    "buy_price": 0,
                    "broker_avg_price": _pf_int_price(br_avg) if br_avg > 0 else None,
                    "current_price": _pf_int_price(cur_px),
                    "pnl_pct": round(pnl_pct, 2),
                    "eval_amt": _pf_int_price(cur_px * bqty) if cur_px > 0 else 0,
                    "buy_date": None,
                    "sync_ok": False,
                    "sync_note": origin_note,
                })
    else:
        for row in rows:
            code = str(row.get("code") or "")
            items.append(
                _portfolio_item_from_row(row, {}, with_broker=False)
            )
    mock = False
    try:
        mock = bool(order_mgr.client.mock)
    except Exception:
        pass
    account_summary = _portfolio_account_summary(order_mgr) if with_broker else None
    return jsonify({
        "ok": True,
        "items": items,
        "count": len(items),
        "kis_mock": mock,
        "broker_codes": len(broker) if with_broker else None,
        "with_broker": with_broker,
        "account": account_summary,
        "strategy_ids": KIS_TRADER_STRATEGY_IDS,
        "filter_prefix": strategy_prefix_for_filter(strategy) or "ALL",
    })


@app.route("/api/portfolio/sell", methods=["POST"])
def api_portfolio_sell():
    """
    시장가 전량 매도 — kis_trader OrderManager._place_sell 과 동일 경로.
    body: { "code", "strategy", "reason"?(optional) }
    """
    from kis_trader.execution.order_manager import OrderRequest
    from kis_trader.utils.env import get_env_from_db

    data = request.get_json(silent=True) or {}
    code = str(data.get("code") or "").strip()
    strategy = str(data.get("strategy") or "").strip()
    if not code or not strategy:
        return jsonify({"ok": False, "error": "code 와 strategy 가 필요합니다."}), 400

    db = _db()
    cur = db.conn.execute(
        "SELECT * FROM active_trades WHERE code=%s AND strategy=%s",
        (code, strategy),
    )
    row = cur.fetchone()
    if not row:
        return jsonify({
            "ok": False,
            "error": f"active_trades 에 없음: {code} [{strategy}]",
        }), 404

    qty = int(row.get("current_qty") or 0)
    if qty <= 0:
        return jsonify({"ok": False, "error": "매도 수량(current_qty)이 0 입니다."}), 400

    order_mgr, market_client = _portfolio_infra()
    buy_px = float(row.get("avg_buy_price") or 0)
    cur_px = _portfolio_live_price(code, row, market_client)
    profit_pct = ((cur_px - buy_px) / buy_px) if buy_px > 0 and cur_px > 0 else 0.0
    default_reason = get_env_from_db("WEB_MANUAL_SELL_REASON", "웹동기화(HTS대체)")
    reason = str(data.get("reason") or default_reason or "웹동기화(HTS대체)")

    # active_trades PK(strategy) 는 행 값 그대로 — close_trade 가 trade_history 에 canonical 저장.
    req = OrderRequest(
        strategy_id=strategy,
        code=code,
        name=str(row.get("name") or code),
        side="SELL",
        qty=qty,
        price_ref=cur_px,
        reason=reason,
        buy_price=buy_px,
        profit_pct=profit_pct,
    )
    try:
        result = order_mgr.place(req)
    except Exception as e:
        logger.exception("portfolio sell %s %s", code, strategy)
        return jsonify({"ok": False, "error": str(e)}), 500

    if result.success:
        return jsonify({
            "ok": True,
            "ord_no": result.ord_no,
            "filled_qty": result.filled_qty,
            "filled_avg_price": result.filled_avg_price,
            "reason": reason,
        })
    return jsonify({
        "ok": False,
        "error": result.reason or "매도 실패",
        "detail": result.reason,
    }), 400


@app.route("/api/portfolio/sell_untracked_all", methods=["POST"])
def api_portfolio_sell_untracked_all():
    """전략 미등록(active_trades 없음) 실계좌 보유분 전량 시장가 일괄매도.

    안전 원칙:
    - active_trades 에 있는 종목(전 전략, HOLDING 포함)은 **절대 건드리지 않음**
      (봇 관리분 보호). 봇이 신호/장마감으로 정상 청산할 대상이므로 제외.
    - 미등록 중에서도 **봇 고아(orders 에 BUY 기록 있음)만** 매도.
      수동매수(orders 기록 없음) 및 MANUAL_HOLD_CODES 지정분은 **보호(제외)**.
    - DB(active_trades/trade_history)는 변경 없음 — 애초에 레코드가 없는 보유분.
    - 잔고 조회 실패 시 안전상 중단(유령 오판 방지).
    - 종목 간 sleep(429 방지) 은 BULK_SELL_INTERVAL_SEC 로 제어(하드코딩 금지).
    """
    import time as _t
    import random as _r
    from kis_trader.utils.env import get_env_float

    order_mgr, _mc = _portfolio_infra()

    # 1) 실계좌 잔고 (fresh) — 실패 시 안전상 중단
    broker = order_mgr.get_broker_holdings(force=True)
    if not getattr(order_mgr, "_holdings_last_fetch_ok", False):
        return jsonify({
            "ok": False,
            "error": "실계좌 잔고 조회 실패 — 안전상 일괄매도 중단",
        }), 503

    # 2) active_trades 전 종목(제외 필터 없이) = 봇 관리분 → 보호 집합
    db = _db()
    tracked: set = set()
    try:
        cur = db.conn.execute("SELECT DISTINCT code FROM active_trades")
        for row in (cur.fetchall() or []):
            c = str(row.get("code") or "").strip()
            if c:
                tracked.add(c)
    except Exception as e:
        logger.error("active_trades 코드 조회 실패: %s", e)
        return jsonify({"ok": False, "error": f"active_trades 조회 실패: {e}"}), 500

    # 2b) origin 판별 집합 (봇 주문기록 / 수동 보호목록)
    bot_bought_codes, manual_hold_codes = _portfolio_origin_sets()

    # 3) 매도 대상 = 미등록 & 봇 고아(orders BUY 있음) & 수동보호목록 아님
    #    수동매수(주문기록 없음) 및 MANUAL_HOLD_CODES 는 protected 로 분류해 제외.
    targets: List[tuple] = []
    protected: List[Dict[str, Any]] = []
    for code, info in (broker or {}).items():
        c = str(code).strip()
        qty = int((info or {}).get("qty") or 0)
        name = str((info or {}).get("name") or c)
        if qty <= 0 or c in tracked:
            continue  # 봇 관리분 또는 0주 — 대상 아님(보호 표시도 불필요)
        if c in manual_hold_codes:
            protected.append({"code": c, "name": name, "qty": qty, "why": "수동보호목록"})
            continue
        if c not in bot_bought_codes:
            protected.append({"code": c, "name": name, "qty": qty, "why": "수동매수추정(주문기록없음)"})
            continue
        targets.append((c, qty, name))

    if not targets:
        return jsonify({
            "ok": True, "sold": [], "failed": [], "protected": protected,
            "sold_count": 0, "failed_count": 0, "protected_count": len(protected),
            "msg": "매도 대상(봇 고아) 종목이 없습니다." + (
                f" (수동/보호 {len(protected)}종목 제외)" if protected else ""
            ),
        })

    # 4) 순차 시장가 매도 (SafeRequest 스로틀 + 추가 sleep 으로 429 방지)
    interval = get_env_float("BULK_SELL_INTERVAL_SEC", 0.3) or 0.3
    sold: List[Dict[str, Any]] = []
    failed: List[Dict[str, Any]] = []
    for c, qty, name in targets:
        try:
            ord_no = order_mgr.client.sell_market_order(c, qty)
            if ord_no:
                sold.append({"code": c, "name": name, "qty": qty, "ord_no": ord_no})
                logger.info("🧹 [미등록일괄매도] %s(%s) %d주 시장가 접수 ODNO=%s",
                            name, c, qty, ord_no)
            else:
                msg = str(getattr(order_mgr.client, "_last_sell_msg1", "") or "주문 실패")
                failed.append({"code": c, "name": name, "qty": qty, "error": msg})
                logger.warning("🧹 [미등록일괄매도] %s(%s) 실패: %s", name, c, msg)
        except Exception as e:
            failed.append({"code": c, "name": name, "qty": qty, "error": str(e)})
            logger.exception("미등록 일괄매도 예외 %s", c)
        # 종목 간 간격 (마지막 종목 뒤에는 불필요하지만 단순화)
        _t.sleep(max(0.0, interval) + _r.uniform(0.0, 0.1))

    # 매도 후 잔고 캐시 무효화 → 다음 조회 정확성
    try:
        order_mgr.invalidate_holdings_cache()
    except Exception:
        pass

    return jsonify({
        "ok": True,
        "sold": sold,
        "failed": failed,
        "protected": protected,
        "sold_count": len(sold),
        "failed_count": len(failed),
        "protected_count": len(protected),
    })


@app.route("/api/portfolio/reconcile_orphans", methods=["POST"])
def api_portfolio_reconcile_orphans():
    """봇 고아(active_trades 미기록) 수동 복구 — 장마감 배치와 동일 로직.

    실계좌 잔고 ↔ active_trades/orders 대조 후, orders BUY 기록이 있는
    미기록 보유분만 active_trades 에 upsert. 수동매수·MANUAL_HOLD_CODES 제외.
    매도는 하지 않음(DB 동기화만).
    """
    from kis_trader.execution.orphan_reconcile import reconcile_orphan_positions

    order_mgr, _mc = _portfolio_infra()
    try:
        result = reconcile_orphan_positions(order_mgr)
    except Exception as e:
        logger.exception("수동 고아복구 실패")
        return jsonify({"ok": False, "error": str(e)}), 500

    if result.get("error"):
        return jsonify({"ok": False, "error": result["error"]}), 503

    return jsonify({
        "ok": True,
        "reconciled": result.get("reconciled") or [],
        "reconciled_count": int(result.get("reconciled_count") or 0),
        "failed": result.get("failed") or [],
        "failed_count": int(result.get("failed_count") or 0),
        "skipped_manual_count": len(result.get("skipped_manual") or []),
        "skipped_tracked_count": len(result.get("skipped_tracked") or []),
        "skipped_no_order_count": len(result.get("skipped_no_order") or []),
    })


@app.route("/api/env/params", methods=["GET"])
def api_env_params():
    """
    config_scalp / config_momentum / config_short … + env_config 병합 스냅샷에서 UI 초기값 반환.
    실매(get_env_from_db) · 웹 · 파라서치가 동일 merged 소스를 사용한다.
    % 단위 변환 및 음수 → 양수 변환까지 수행해 JS가 바로 input.value에 넣을 수 있도록 함.
    값이 DB에 없으면 null 반환 → JS에서 기존 HTML 기본값 유지.
    """
    db = _db()
    try:
        snap = db.get_merged_env_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

        # 모멘텀·스캘핑·돌파 — config_* + env_config 병합 (파라서치 JSON 덮어쓰기 없음)
        from kis_trader.engine.momentum_engine import get_momentum_defaults_from_db as _mom_def_db
        _mom_ui = _momentum_ui_defaults_from_db(_mom_def_db())
        _scalp_ui = _scalp_ui_defaults_from_db()
        _bo_ui = _bo_defaults_from_db()
        _rb_ui = _rb_defaults_from_db()
        # 당일 누적손익 다단 트레일 현재값 + 프리셋 목록(꼬리와 공유) — 모멘텀·돌파
        _dt_presets = str(snap.get("BT_DAILY_TRAIL_PRESETS") or "").strip()
        for _ui, _pfx in ((_mom_ui, "MOMENTUM"), (_bo_ui, "BREAKOUT")):
            if isinstance(_ui, dict):
                _ui["daily_trail_tiers"] = str(snap.get(f"{_pfx}_DAILY_PROFIT_TRAIL_TIERS") or "").strip()
                _ui["daily_profit_mode"] = (
                    str(snap.get(f"{_pfx}_DAILY_PROFIT_MODE") or "trailing").strip().lower() or "trailing"
                )
                _ui["daily_trail_presets"] = _dt_presets
        return jsonify({
            "scalp": _scalp_ui,
            "tail": _tail_ui_defaults_from_db(snap),
            "momentum": _mom_ui,
            "breakout": _bo_ui,
            "range_break": _rb_ui,
            "dbband": _dbband_ui_defaults_from_db(snap) if _DBBAND_ENGINE_AVAILABLE else {},
        })
    finally:
        db.close()



@app.route("/api/live_config", methods=["GET"])
def api_live_config_get():
    """실매 운영 설정 탭 — 스키마 + 현재값 + 당일 익절·손익 상태."""
    from kis_trader.web.live_config_schema import (
        LIVE_STRATEGY_IDS,
        build_live_config_groups,
        read_snap_value,
        snap_value_to_ui,
    )

    day = (request.args.get("date") or "").strip()[:10]
    if not day:
        day = datetime.now().strftime("%Y-%m-%d")
    db = _db()
    try:
        snap = db.get_merged_env_snapshot() or {}
        try:
            env_db_cols = db._env_config_column_set()
        except Exception:
            env_db_cols = set()
        groups_out: List[Dict[str, Any]] = []
        for g in build_live_config_groups():
            fields_out = []
            for f in g.get("fields") or []:
                key = str(f.get("key") or "")
                ftype = str(f.get("type") or "text")
                ui_val = read_snap_value(snap, key, ftype)
                if ui_val is None and f.get("default") is not None:
                    ui_val = f.get("default")
                tbl = _classify_key_table(key)
                if tbl == "env_config" and key not in env_db_cols:
                    tbl = "env_config_ext"
                fields_out.append({
                    **f,
                    "value": ui_val,
                    "raw": snap.get(key),
                    "table": tbl,
                })
            groups_out.append({
                "id": g.get("id"),
                "title": g.get("title"),
                "hint": g.get("hint"),
                "fields": fields_out,
            })
        status = _build_live_config_status(db, day, snap)
        return jsonify({
            "ok": True,
            "date": day,
            "as_of": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
            "groups": groups_out,
            "status": status,
        })
    except Exception as e:
        logger.exception("live_config GET 실패")
        return jsonify({"ok": False, "error": str(e)}), 500
    finally:
        db.close()


@app.route("/api/live_config/save", methods=["POST"])
def api_live_config_save():
    """운영 설정 탭 — 허용 키만 patch 후 insert_env_snapshot."""
    from kis_trader.web.live_config_schema import (
        all_live_config_keys,
        build_live_config_groups,
        expand_live_config_save_patch,
        ui_value_to_db,
    )

    body = request.get_json(force=True, silent=True) or {}
    patch_in = body.get("patch") or body.get("values") or body
    if not isinstance(patch_in, dict) or not patch_in:
        return jsonify({"ok": False, "error": "patch 객체 필요"}), 400

    allowed = set(all_live_config_keys())
    key_types: Dict[str, str] = {}
    for g in build_live_config_groups():
        for f in g.get("fields") or []:
            key_types[str(f.get("key"))] = str(f.get("type") or "text")

    patch: Dict[str, str] = {}
    rejected: List[str] = []
    for k, v in patch_in.items():
        key = str(k).strip()
        if key not in allowed:
            rejected.append(key)
            continue
        ftype = key_types.get(key, "text")
        patch[key] = ui_value_to_db(v, ftype)

    patch = expand_live_config_save_patch(patch)

    if not patch:
        return jsonify({
            "ok": False,
            "error": "저장할 유효 키 없음",
            "rejected": rejected,
        }), 400

    db = _db()
    try:
        snap = db.get_merged_env_snapshot() or {}
        for k, v in patch.items():
            snap[k] = v
        env_id = db.insert_env_snapshot(snap)
        if env_id is None:
            return jsonify({"ok": False, "error": "insert_env_snapshot 실패"}), 500
        saved_by_table: Dict[str, List[str]] = {}
        for k in patch:
            tbl = _classify_key_table(k)
            saved_by_table.setdefault(tbl, []).append(k)
        # overflow 키 표시
        try:
            db_cols = db._env_config_column_set()
            for k in patch:
                if _classify_key_table(k) == "env_config" and k not in db_cols:
                    saved_by_table.setdefault("env_config_ext", []).append(k)
        except Exception:
            pass
        return jsonify({
            "ok": True,
            "env_id": env_id,
            "saved_keys": list(patch.keys()),
            "saved_by_table": saved_by_table,
            "rejected": rejected,
        })
    except Exception as e:
        logger.exception("live_config 저장 실패")
        return jsonify({"ok": False, "error": str(e)}), 500
    finally:
        db.close()


def _classify_key_table(key: str) -> str:
    from config_schema import classify_config_key
    return classify_config_key(key)


def _build_live_config_status(
    db: TradeDB, day_iso: str, snap: Dict[str, str],
) -> Dict[str, Any]:
    """당일 봇 실현손익 + 일일 익절 달성(매수중단) 여부."""
    from kis_trader.engine.daily_profit_halt import (
        load_global_profit_target,
        load_strategy_profit_target,
        resolve_global_operating_budget_krw,
        resolve_strategy_budget_krw,
        _target_configured,
        _target_reached,
    )
    from kis_trader.web.live_config_schema import LIVE_STRATEGY_IDS

    dash = _build_actual_dashboard(db, day_iso)
    totals = dash.get("totals") or {}
    strat_rows = {
        str(r.get("strategy_id")): r
        for r in (dash.get("strategies") or [])
    }

    active_sids = [
        sid for sid in LIVE_STRATEGY_IDS
        if _strategy_enabled_from_snapshot(snap, sid)
    ]

    gcfg = load_global_profit_target()
    gpnl = float(totals.get("realized_pnl_krw") or 0)
    gbudget = resolve_global_operating_budget_krw(active_sids)
    g_hit = _target_reached(gpnl, gcfg, gbudget) if _target_configured(gcfg) else False

    strategies_out: List[Dict[str, Any]] = []
    for sid in LIVE_STRATEGY_IDS:
        row = strat_rows.get(sid) or {}
        spnl = float(row.get("realized_pnl_krw") or 0)
        scfg = load_strategy_profit_target(sid)
        sbudget = resolve_strategy_budget_krw(sid)
        s_hit = (
            _target_reached(spnl, scfg, sbudget)
            if _target_configured(scfg)
            else False
        )
        strategies_out.append({
            "strategy_id": sid,
            "label": _ACTUAL_DASHBOARD_LABELS.get(sid, sid),
            "enabled": _strategy_enabled_from_snapshot(snap, sid),
            "realized_pnl_krw": int(round(spnl)),
            "budget_limit_krw": int(round(sbudget or row.get("budget_limit_krw") or 0)),
            "return_pct": float(row.get("return_pct") or 0),
            "profit_target_hit": s_hit,
            "buy_halted": g_hit or s_hit,
        })

    return {
        "global": {
            "realized_pnl_krw": int(round(gpnl)),
            "budget_krw": int(round(gbudget)),
            "return_pct": float(totals.get("return_pct") or 0),
            "profit_target_hit": g_hit,
            "buy_halted": g_hit,
            "target_enabled": bool(gcfg.get("enabled")),
            "target_krw": float(gcfg.get("krw") or 0),
            "target_pct": float(gcfg.get("pct") or 0),
        },
        "strategies": strategies_out,
        "notes": {
            "pnl_source": "trade_history 당일 실현 (수수료·세금 반영, 봇 실현과 동일)",
            "halt_scope": "신규 매수만 중단 — 보유 종목 손절·익절 유지",
        },
    }


@app.route("/")
def index():
    return render_template("backtest.html")


if __name__ == "__main__":
    def _warm_portfolio():
        try:
            _portfolio_infra()
            logger.info("보유·매도 API 워밍업 완료")
        except Exception as e:
            logger.warning("보유·매도 워밍업 스킵: %s", e)

    threading.Thread(target=_warm_portfolio, daemon=True).start()
    app.run(host="0.0.0.0", port=5050, debug=False, threaded=True)
