// ────────────────────────────────────────────
// 유틸
// ────────────────────────────────────────────
const $ = id => document.getElementById(id);
const fmt = n => n == null ? '-' : Number(n).toLocaleString('ko-KR');
/** 원화 가격·평가금 — 소수점 없이 정수 (KIS 주식 호가 단위) */
const fmtWon = n => {
  if (n == null || n === '' || !Number.isFinite(Number(n))) return '—';
  return Math.round(Number(n)).toLocaleString('ko-KR');
};
/** 원화 금액·손익 — 소수점 없이 정수 + '원' (대시보드·요약 카드) */
const fmtKrw = n => {
  const w = fmtWon(n);
  return w === '—' ? w : w + '원';
};
/** 수익률(%) — 거래내역·가상체결 테이블 공용 */
const fmtPct = n => {
  if (n == null || n === '' || !Number.isFinite(Number(n))) return '—';
  const v = Number(n);
  return (v >= 0 ? '+' : '') + v.toFixed(2) + '%';
};
/** 가상 거래 내역 — 매도/청산 시각 기준 최신순 (API가 최신순이어도 프론트에서 재정렬) */
function tradeExitSortKey(t) {
  const keys = ['exit_time', 'sell_time', 'sell_date', 'buy_time', 'entry_time', 'buy_date'];
  for (const k of keys) {
    const v = t && t[k];
    if (v != null && String(v).trim()) {
      return String(v).replace(/[-: T]/g, '').slice(0, 14);
    }
  }
  return '';
}
function tradesNewestFirst(trades) {
  return [...(trades || [])].sort((a, b) => tradeExitSortKey(b).localeCompare(tradeExitSortKey(a)));
}

/** 가상 거래 내역 — 전략 공통 렌더·정렬 */
const _virtualTradesCache = {};
const _virtualTradesSort = {};
const _virtualTradesOpts = {};

function fmtTradeTime(raw) {
  const s = String(raw || '').trim();
  if (!s) return '';
  // ISO 형식('2026-06-26 09:30:25') — 초(SS)까지 표시. 날짜만(<=10자)이면 그대로.
  if (s.includes('-') && (s.includes(':') || s.length <= 10)) {
    return s.length <= 10 ? s : s.slice(0, 19);
  }
  const d = s.replace(/[-: T]/g, '');
  if (d.length >= 14) {
    return d.slice(0, 4) + '-' + d.slice(4, 6) + '-' + d.slice(6, 8) + ' '
      + d.slice(8, 10) + ':' + d.slice(10, 12) + ':' + d.slice(12, 14);
  }
  if (d.length >= 12) {
    return d.slice(0, 4) + '-' + d.slice(4, 6) + '-' + d.slice(6, 8) + ' ' + d.slice(8, 10) + ':' + d.slice(10, 12);
  }
  if (d.length >= 8) return d.slice(0, 4) + '-' + d.slice(4, 6) + '-' + d.slice(6, 8);
  return s;
}

function _tradeTimeSortKey(raw) {
  const s = String(raw || '').trim();
  if (!s) return '';
  return s.replace(/[-: T]/g, '').slice(0, 14);
}

function normalizeVirtualTrade(t, meta) {
  meta = meta || {};
  const ep = Math.round(Number(t.buy_price ?? t.avg_price ?? t.entry ?? 0));
  const xp = Math.round(Number(t.sell_price ?? t.exit_price ?? t.exit ?? 0));
  const qty = Number(t.qty || 0) || (ep > 0 ? Math.max(1, Math.floor(1000000 / ep)) : 0);
  const pnl = Math.round(Number(t.pnl ?? t.realized_pnl ?? t.unrealized_pnl ?? 0));
  let rateNum = Number(t.profit_rate);
  if (!Number.isFinite(rateNum) && ep > 0) rateNum = (xp - ep) / ep * 100;
  if (!Number.isFinite(rateNum)) rateNum = 0;
  const isOpen = !!t.is_open;
  const buyRaw = t.buy_time || t.entry_time || t.buy_date || '';
  const sellRaw = isOpen ? '' : (t.sell_time || t.exit_time || t.sell_date || '');
  const code = t.code || meta.code || '';
  const name = t.name || meta.name || '';
  return {
    code,
    name,
    buyRaw,
    sellRaw,
    buyKey: _tradeTimeSortKey(buyRaw),
    sellKey: isOpen ? _tradeTimeSortKey(buyRaw) : _tradeTimeSortKey(sellRaw),
    ep,
    xp,
    qty,
    pnl,
    isOpen,
    rateNum,
    hold: t.hold_min ?? t.hold_days ?? t.hold_minutes ?? 0,
    reason: t.sell_reason || t.reason || '',
    rsi: t.rsi_entry,
    entrySource: t.entry_source_label || t.entry_source || '',
    exitSource: t.exit_source_label || t.exit_source || '',
    debugTick: t.debug_tick || '',
    cumPnl: (() => { const v = Number(t.cum_pnl); return Number.isFinite(v) ? Math.round(v) : NaN; })(),
    cumReturnPct: Number(t.cum_return_pct ?? NaN),
  };
}

function sortVirtualTradeRows(rows, mode) {
  const list = [...rows];
  const cmpExit = (a, b) => String(a.sellKey).localeCompare(String(b.sellKey));
  switch (mode) {
    case 'exit_asc': return list.sort(cmpExit);
    case 'pnl_desc': return list.sort((a, b) => b.pnl - a.pnl);
    case 'pnl_asc': return list.sort((a, b) => a.pnl - b.pnl);
    case 'rate_desc': return list.sort((a, b) => b.rateNum - a.rateNum);
    case 'rate_asc': return list.sort((a, b) => a.rateNum - b.rateNum);
    case 'exit_desc':
    default: return list.sort((a, b) => cmpExit(b, a));
  }
}

function _syncTradeSortButtons(tbodyId, mode) {
  document.querySelectorAll(`.trade-sort-bar[data-tbody="${tbodyId}"] .trade-sort-btn`).forEach(btn => {
    btn.classList.toggle('active', btn.dataset.sort === mode);
  });
}

function renderVirtualTrades(tbodyId, trades, opts) {
  opts = opts || {};
  _virtualTradesCache[tbodyId] = trades || [];
  _virtualTradesOpts[tbodyId] = opts;
  if (!_virtualTradesSort[tbodyId]) _virtualTradesSort[tbodyId] = 'exit_desc';
  const mode = _virtualTradesSort[tbodyId];
  const meta = opts.meta || {};
  const rows = sortVirtualTradeRows(
    (_virtualTradesCache[tbodyId] || []).map(t => normalizeVirtualTrade(t, meta)),
    mode,
  );
  const tbody = $(tbodyId);
  if (!tbody) return;
  tbody.innerHTML = '';
  rows.forEach(r => {
    const pnlCls = r.pnl > 0 ? 'text-pnl-pos' : (r.pnl < 0 ? 'text-pnl-neg' : '');
    const rateBadge = r.isOpen
      ? 'badge-flat'
      : (r.rateNum > 0 ? 'badge-win' : (r.rateNum < 0 ? 'badge-loss' : 'badge-flat'));
    const nameCell = (r.name || r.code)
      ? `${r.name || r.code}<br><span style="color:var(--muted);font-size:11px">${r.code || ''}</span>`
      : (r.code || '-');
    const reasonHtml = `<span class="badge-reason">${r.reason || '-'}</span>`;
    const debugHtml = r.debugTick
      ? `<span class="badge badge-secondary" style="font-size:10px" title="체결 경로">${r.debugTick}</span>`
      : (r.entrySource || r.exitSource
        ? `<span style="font-size:10px;color:var(--muted)">${r.entrySource || '-'}→${r.exitSource || '-'}</span>`
        : '-');
    const cumPnlHtml = Number.isFinite(r.cumPnl)
      ? `<span class="${r.cumPnl > 0 ? 'text-pnl-pos' : (r.cumPnl < 0 ? 'text-pnl-neg' : '')}">${fmtWon(r.cumPnl)}</span>`
      : '-';
    const cumPctHtml = Number.isFinite(r.cumReturnPct)
      ? `<span class="${r.cumReturnPct > 0 ? 'badge-win' : (r.cumReturnPct < 0 ? 'badge-loss' : 'badge-flat')}">${fmtPct(r.cumReturnPct)}</span>`
      : '-';
    const sellCell = r.isOpen
      ? '<span class="badge-flat">보유중</span>'
      : `<span style="font-size:11px">${fmtTradeTime(r.sellRaw)}</span>`;
    const exitPxCell = r.isOpen
      ? `${fmtWon(r.xp)} <span style="color:var(--muted);font-size:10px">현재가</span>`
      : fmtWon(r.xp);
    const pnlCell = r.isOpen
      ? `${fmtWon(r.pnl)} <span style="color:var(--muted);font-size:10px">평가</span>`
      : fmtWon(r.pnl);
    const rowStyle = r.isOpen ? ' style="background:rgba(210,153,34,.06)"' : '';
    let html = `<tr${rowStyle}>
      <td>${nameCell}</td>
      <td style="font-size:11px">${fmtTradeTime(r.buyRaw)}</td>
      <td style="font-size:11px">${sellCell}</td>
      <td>${fmtWon(r.ep)}</td>
      <td>${exitPxCell}</td>
      <td>${r.qty}</td>
      <td class="${pnlCls}">${pnlCell}</td>
      <td><span class="${rateBadge}">${fmtPct(r.rateNum)}</span></td>`;
    if (opts.showCumulative) {
      html += `<td>${cumPnlHtml}</td><td>${cumPctHtml}</td>`;
    }
    html += `<td>${r.hold}</td>`;
    if (opts.showDebug) html += `<td style="font-size:10px">${debugHtml}</td>`;
    html += `<td style="font-size:11px">${reasonHtml}</td>`;
    if (opts.showRsi) html += `<td>${r.rsi != null && r.rsi !== '' ? r.rsi : '-'}</td>`;
    html += '</tr>';
    tbody.insertAdjacentHTML('beforeend', html);
  });
  _syncTradeSortButtons(tbodyId, mode);
}

function setVirtualTradesSort(tbodyId, mode) {
  _virtualTradesSort[tbodyId] = mode;
  if (_virtualTradesCache[tbodyId]) {
    renderVirtualTrades(tbodyId, _virtualTradesCache[tbodyId], _virtualTradesOpts[tbodyId] || {});
  }
}

document.addEventListener('click', e => {
  const btn = e.target.closest('.trade-sort-btn');
  if (!btn) return;
  const bar = btn.closest('.trade-sort-bar');
  if (!bar || !bar.dataset.tbody) return;
  setVirtualTradesSort(bar.dataset.tbody, btn.dataset.sort || 'exit_desc');
});
const colorPnl = (el, val) => {
  if (!el) return;
  el.classList.remove('green','red');
  if (val > 0) el.classList.add('green');
  else if (val < 0) el.classList.add('red');
};

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

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

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

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

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

// ────────────────────────────────────────────
// 탭 전환
// ────────────────────────────────────────────
document.querySelectorAll('[data-tab]').forEach(el => {
  el.addEventListener('click', e => {
    e.preventDefault();
    document.querySelectorAll('[data-tab]').forEach(x => x.classList.remove('active'));
    el.classList.add('active');
    const tab = el.dataset.tab;
    $('tab-actual').style.display    = tab === 'actual'   ? '' : 'none';
    $('tab-dashboard').style.display = tab === 'dashboard'? '' : 'none';
    $('tab-portfolio').style.display = tab === 'portfolio'? '' : 'none';
    $('tab-backtest').style.display  = tab === 'backtest' ? '' : 'none';
    $('tab-tail').style.display      = tab === 'tail'     ? '' : 'none';
    $('tab-dbband').style.display   = tab === 'dbband'  ? '' : 'none';
    $('tab-breakout').style.display  = tab === 'breakout' ? '' : 'none';
    $('tab-range_break').style.display = tab === 'range_break' ? '' : 'none';
    $('tab-momentum').style.display  = tab === 'momentum' ? '' : 'none';
    $('tab-holding').style.display   = tab === 'holding'  ? '' : 'none';
    $('tab-updownbox').style.display = tab === 'updownbox'? '' : 'none';
    $('tab-updow').style.display     = tab === 'updow'    ? '' : 'none';
    $('tab-liveconfig').style.display = tab === 'liveconfig' ? '' : 'none';
    if (tab === 'dashboard') loadDashboard();
    if (tab === 'liveconfig') lcOnTabShow();
    if (tab === 'portfolio') pfLoad(true);
    if (tab === 'holding') hdLoadStocks();
    if (tab === 'updow') permLoad();
    if (tab === 'updownbox') ubxOnTabShow();
    if (tab === 'dbband') dbLoadStocks();
    if (tab === 'breakout' && typeof boSyncSlModeColors === 'function') boSyncSlModeColors();
  });
});

// ════════════════════════════════════════════════════════════════
// 영구구독 관리 (permanent_subscriptions: KR 국내WS / US 해외WS)
// ════════════════════════════════════════════════════════════════
function permOnMarket() {
  // 시장 선택 시 거래소 기본값 자동 보정 (KR→KRX, US→NASD)
  const mk = $('perm_market') ? $('perm_market').value : 'KR';
  if ($('perm_exch')) $('perm_exch').value = (mk === 'US') ? 'NASD' : 'KRX';
}

async function permLoad() {
  const tb = $('perm_tbody');
  if (!tb) return;
  try {
    const r = await fetch('/api/permanent_subs');
    const j = await r.json();
    const rows = (j && j.rows) || [];
    if (!rows.length) { tb.innerHTML = '<tr><td colspan="9" class="text-muted">등록된 영구구독 없음</td></tr>'; return; }
    tb.innerHTML = rows.map(function (x) {
      const onBadge = x.enabled
        ? '<span class="badge bg-success">ON</span>'
        : '<span class="badge bg-secondary">OFF</span>';
      const mkBadge = (String(x.market_type).toUpperCase() === 'US')
        ? '<span class="badge bg-info text-dark">US</span>'
        : '<span class="badge bg-primary">KR</span>';
      return '<tr>'
        + '<td><b>' + x.code + '</b></td>'
        + '<td>' + mkBadge + '</td>'
        + '<td>' + (x.exchange || '') + '</td>'
        + '<td>' + (x.symbol || '') + '</td>'
        + '<td>' + (x.tf_min || '') + '</td>'
        + '<td><code>' + (x.ws_tr_key || '') + '</code></td>'
        + '<td>' + onBadge + '</td>'
        + '<td>' + (x.note || '') + '</td>'
        + '<td><button class="btn btn-sm btn-outline-secondary" onclick="permEdit(' + JSON.stringify(JSON.stringify(x)).replace(/"/g, '&quot;') + ')">수정</button> '
        + '<button class="btn btn-sm btn-outline-danger" onclick="permDelete(\'' + x.code + '\')">삭제</button></td>'
        + '</tr>';
    }).join('');
  } catch (e) {
    tb.innerHTML = '<tr><td colspan="9" class="text-danger">로드 실패: ' + e + '</td></tr>';
  }
}

function permEdit(jsonStr) {
  let x;
  try { x = JSON.parse(jsonStr); } catch (e) { return; }
  if ($('perm_code'))   $('perm_code').value   = x.code || '';
  if ($('perm_market')) $('perm_market').value = String(x.market_type || 'KR').toUpperCase();
  if ($('perm_exch'))   $('perm_exch').value   = x.exchange || 'KRX';
  if ($('perm_symbol')) $('perm_symbol').value = x.symbol || '';
  if ($('perm_tf'))     $('perm_tf').value     = x.tf_min || 15;
  if ($('perm_note'))   $('perm_note').value   = x.note || '';
}

async function permSave() {
  const code = ($('perm_code') ? $('perm_code').value : '').trim().toUpperCase();
  if (!code) { alert('코드를 입력하세요'); return; }
  const payload = {
    code: code,
    market_type: $('perm_market') ? $('perm_market').value : 'KR',
    exchange:    $('perm_exch') ? $('perm_exch').value : '',
    symbol:      $('perm_symbol') ? $('perm_symbol').value.trim().toUpperCase() : '',
    tf_min:      $('perm_tf') ? parseInt($('perm_tf').value || '15', 10) : 15,
    enabled:     true,
    note:        $('perm_note') ? $('perm_note').value : '',
  };
  try {
    const r = await fetch('/api/permanent_subs/save', {
      method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
    });
    const j = await r.json();
    if (!j.ok) { alert('저장 실패: ' + (j.error || '')); return; }
    if ($('perm_code'))   $('perm_code').value = '';
    if ($('perm_symbol')) $('perm_symbol').value = '';
    if ($('perm_note'))   $('perm_note').value = '';
    permLoad();
  } catch (e) { alert('저장 오류: ' + e); }
}

async function permDelete(code) {
  if (!confirm(code + ' 영구구독을 삭제할까요?')) return;
  try {
    const r = await fetch('/api/permanent_subs/delete', {
      method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code: code }),
    });
    const j = await r.json();
    if (!j.ok && j.error) { alert('삭제 실패: ' + j.error); }
    permLoad();
  } catch (e) { alert('삭제 오류: ' + e); }
}

// ────────────────────────────────────────────
// 오늘 운영 대시보드
// ────────────────────────────────────────────
function dashFmtKrw(n) {
  return fmtKrw(n);
}
function dashFmtPct(n) {
  const v = Number(n) || 0;
  return (v >= 0 ? '+' : '') + v.toFixed(2) + '%';
}
function dashPnlCls(n) {
  return Number(n) >= 0 ? 'text-pnl-pos' : 'text-pnl-neg';
}
function dashInitDate() {
  const el = $('dash_date');
  if (!el) return;
  if (!el.value) {
    const t = new Date();
    const y = t.getFullYear();
    const m = String(t.getMonth() + 1).padStart(2, '0');
    const d = String(t.getDate()).padStart(2, '0');
    el.value = y + '-' + m + '-' + d;
  }
}
async function loadDashboard() {
  dashInitDate();
  const day = $('dash_date').value;
  showSpinner(true);
  try {
    const r = await fetch('/api/actual/dashboard?date=' + encodeURIComponent(day));
    const d = await r.json();
    if (!d.ok) { alert(d.error || '대시보드 조회 실패'); return; }
    const tot = d.totals || {};
    $('dash_meta').textContent = '기준일 ' + d.date + ' · 갱신 ' + (d.as_of || '');
    $('dash_card_turnover').textContent = dashFmtKrw(tot.turnover_krw);
    $('dash_card_turnover_sub').textContent =
      '매수 ' + dashFmtKrw(tot.buy_turnover_krw) + ' · 매도 ' + dashFmtKrw(tot.sell_turnover_krw);
    $('dash_card_budget').textContent = dashFmtPct(tot.budget_usage_pct);
    $('dash_card_budget_sub').textContent =
      '피크 ' + dashFmtKrw(tot.budget_used_krw) + ' · 현재 ' + dashFmtKrw(tot.budget_now_krw || 0)
      + ' / 한도 ' + dashFmtKrw(tot.budget_limit_krw);
    $('dash_card_pnl').textContent = dashFmtKrw(tot.realized_pnl_krw);
    $('dash_card_pnl').className = 'stat-value ' + dashPnlCls(tot.realized_pnl_krw);
    $('dash_card_ret').textContent = dashFmtPct(tot.return_pct);
    $('dash_card_ret').className = 'stat-value ' + dashPnlCls(tot.return_pct);

    const tbody = $('dash_tbody');
    tbody.innerHTML = '';
    (d.strategies || []).forEach(row => {
      const onBadge = row.enabled
        ? '<span class="badge-win">ON</span>'
        : '<span class="badge-loss">OFF</span>';
      const sid = row.strategy_id;
      const pnlCls = dashPnlCls(row.realized_pnl_krw);
      const retCls = dashPnlCls(row.return_pct);
      tbody.insertAdjacentHTML('beforeend', `
        <tr>
          <td>${row.label || sid}<br><code style="font-size:11px">${sid}</code></td>
          <td>${onBadge}</td>
          <td class="text-end">${dashFmtKrw(row.buy_turnover_krw)}</td>
          <td class="text-end">${dashFmtKrw(row.sell_turnover_krw)}</td>
          <td class="text-end">${dashFmtKrw(row.turnover_krw)}</td>
          <td class="text-end">${row.closed_trades}</td>
          <td class="text-end">${row.open_positions}</td>
          <td class="text-end ${pnlCls}">${dashFmtKrw(row.realized_pnl_krw)}</td>
          <td class="text-end">${dashFmtKrw(row.budget_limit_krw)}</td>
          <td class="text-end">${dashFmtKrw(row.budget_used_krw)}</td>
          <td class="text-end text-muted">${dashFmtKrw(row.budget_now_krw || 0)}</td>
          <td class="text-end">${dashFmtPct(row.budget_usage_pct)}</td>
          <td class="text-end ${retCls}">${dashFmtPct(row.return_pct)}</td>
          <td><button class="btn btn-sm btn-link p-0" style="font-size:11px"
              onclick="dashDrill('${sid}')">상세</button></td>
        </tr>`);
    });
    $('dash_ft_buy').textContent = dashFmtKrw(tot.buy_turnover_krw);
    $('dash_ft_sell').textContent = dashFmtKrw(tot.sell_turnover_krw);
    $('dash_ft_turn').textContent = dashFmtKrw(tot.turnover_krw);
    $('dash_ft_closed').textContent = String(tot.closed_trades);
    $('dash_ft_open').textContent = String(tot.open_positions);
    $('dash_ft_pnl').textContent = dashFmtKrw(tot.realized_pnl_krw);
    $('dash_ft_pnl').className = 'text-end ' + dashPnlCls(tot.realized_pnl_krw);
    $('dash_ft_limit').textContent = dashFmtKrw(tot.budget_limit_krw);
    $('dash_ft_used').textContent = dashFmtKrw(tot.budget_used_krw);
    $('dash_ft_now').textContent = dashFmtKrw(tot.budget_now_krw || 0);
    $('dash_ft_usage').textContent = dashFmtPct(tot.budget_usage_pct);
    $('dash_ft_ret').textContent = dashFmtPct(tot.return_pct);
    $('dash_ft_ret').className = 'text-end ' + dashPnlCls(tot.return_pct);
    const notes = d.notes || {};
    $('dash_notes').textContent =
      (notes.turnover || '') + ' · ' + (notes.budget_used || '') + ' · '
      + (notes.budget_now || '') + ' · ' + (notes.return_pct || '');
  } catch (e) {
    alert('대시보드 오류: ' + e);
  } finally {
    showSpinner(false);
  }
}
function dashDrill(strategyId) {
  const radio = document.querySelector('input[name=act_strategy][value="' + strategyId + '"]');
  if (radio) radio.checked = true;
  const day = $('dash_date').value;
  if (day) {
    $('act_start').value = day;
    $('act_end').value = day;
  }
  document.querySelectorAll('[data-tab]').forEach(x => x.classList.remove('active'));
  const actTab = document.querySelector('[data-tab="actual"]');
  if (actTab) actTab.classList.add('active');
  $('tab-dashboard').style.display = 'none';
  $('tab-actual').style.display = '';
  $('tab-portfolio').style.display = 'none';
  $('tab-backtest').style.display = 'none';
  $('tab-tail').style.display = 'none';
  $('tab-breakout').style.display = 'none';
  $('tab-range_break').style.display = 'none';
  $('tab-momentum').style.display = 'none';
  $('tab-holding').style.display = 'none';
  $('tab-updow').style.display = 'none';
  loadActual();
}
function dashGoActual() {
  dashDrill(document.querySelector('input[name=act_strategy]:checked')?.value || 'SHORT');
}
dashInitDate();

// ────────────────────────────────────────────
// 보유·매도 (active_trades + OrderManager 시장가)
// ────────────────────────────────────────────
// 보유·매도 정렬 상태 (다른 탭의 정렬바와 동일 UX, 데이터 스키마만 다름)
let _pfItems = [];
let _pfMeta = null;
let _pfSort = 'pnl_desc';

/** 보유·매도 정렬 — 수익률/평가금/종목명/미등록 우선 */
function pfSortItems(items, mode) {
  const arr = [...(items || [])];
  const num = v => (Number.isFinite(Number(v)) ? Number(v) : 0);
  switch (mode) {
    case 'pnl_asc':  return arr.sort((a, b) => num(a.pnl_pct) - num(b.pnl_pct));
    case 'eval_desc': return arr.sort((a, b) => num(b.eval_amt) - num(a.eval_amt));
    case 'eval_asc':  return arr.sort((a, b) => num(a.eval_amt) - num(b.eval_amt));
    case 'name_asc':  return arr.sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''), 'ko'));
    case 'untracked_first':
      return arr.sort((a, b) => (b.untracked ? 1 : 0) - (a.untracked ? 1 : 0) || num(b.eval_amt) - num(a.eval_amt));
    case 'pnl_desc':
    default: return arr.sort((a, b) => num(b.pnl_pct) - num(a.pnl_pct));
  }
}

async function pfLoad(withBroker) {
  const stratEl = document.querySelector('input[name=pf_strategy]:checked');
  const strat = stratEl ? stratEl.value : 'ALL';
  const brokerQ = withBroker ? '&broker=1' : '';
  showSpinner(true);
  try {
    const r = await fetch(
      '/api/portfolio/active?strategy=' + encodeURIComponent(strat) + brokerQ
    );
    const d = await r.json();
    if (!d.ok) { alert(d.error || '조회 실패'); return; }
    _pfItems = d.items || [];
    _pfMeta = {
      kis_mock: d.kis_mock,
      with_broker: d.with_broker,
      broker_codes: d.broker_codes,
      count: d.count,
      account: d.account || null,
    };
    pfRenderAccountSummary(d.account, d.with_broker);
    pfRenderTable();
  } catch (e) {
    alert('보유 조회 오류: ' + e);
  } finally {
    showSpinner(false);
  }
}

/** 보유·매도 — 매수시각 강조 (날짜 + 시각 분리) */
function pfFmtBuyDate(s) {
  if (!s) return '<span class="text-muted">—</span>';
  const t = String(s).trim().replace('T', ' ');
  const d = t.slice(0, 10);
  const tm = t.length >= 16 ? t.slice(11, 16) : (t.length > 10 ? t.slice(11) : '');
  if (tm) {
    return `<b>${d}</b><br><span style="color:var(--accent);font-size:12px;font-weight:600">${tm}</span>`;
  }
  return `<b>${d}</b>`;
}

/** 실계좌 대조 시 상단 — 입금액·예수금·평가금 */
function pfRenderAccountSummary(acct, withBroker) {
  const row = $('pf_account_row');
  if (!row) return;
  if (!withBroker || !acct) {
    row.style.display = 'none';
    return;
  }
  row.style.display = '';
  const set = (id, v, suffix) => {
    const el = $(id);
    if (!el) return;
    el.textContent = (v != null && v !== '') ? fmtWon(v) + (suffix || '원') : '—';
  };
  set('pf_total_deposit', acct.total_deposit, '원');
  set('pf_cash', acct.cash, '원');
  set('pf_holdings_eval', acct.holdings_eval, '원');
  set('pf_total_asset', acct.total_asset, '원');
}

/** 캐시된 _pfItems 를 현재 정렬(_pfSort)로 렌더 (재조회 없이 정렬만 갱신) */
function pfRenderTable() {
  const d = _pfMeta || {};
  const tbody = $('pf_tbody');
  if (!tbody) return;
  tbody.innerHTML = '';
  const items = pfSortItems(_pfItems, _pfSort);
  if ($('pf_empty')) $('pf_empty').style.display = items.length ? 'none' : '';
  const mockLbl = d.kis_mock ? '모의투자' : '실전';
  const untracked = items.filter(x => x.untracked).length;
  const brNote = d.with_broker
    ? ` · 실계좌 ${d.broker_codes}종목` + (untracked ? ` (미등록 ${untracked})` : '')
    : ' · 「실계좌 대조」필요';
  if ($('pf_meta')) {
    $('pf_meta').textContent =
      `${mockLbl} · 표시 ${d.count}건${brNote} · ${new Date().toLocaleString('ko-KR')}`;
  }
  // 정렬 버튼 active 동기화
  document.querySelectorAll('#pf_sort_bar .pf-sort-btn').forEach(btn => {
    btn.classList.toggle('active', btn.dataset.sort === _pfSort);
  });
  items.forEach(it => {
      const pnlCls = it.pnl_pct >= 0 ? 'text-pnl-pos' : 'text-pnl-neg';
      let syncBadge = '<span class="text-muted">—</span>';
      if (d.with_broker) {
        if (it.untracked) {
          let lbl, cls;
          if (it.origin === 'holding') {
            lbl = '홀딩봇'; cls = 'badge-win';
          } else if (it.origin === 'bot') {
            lbl = '봇고아'; cls = 'badge-loss';
          } else {
            lbl = '수동(보호)'; cls = 'badge-flat';
          }
          syncBadge = '<span class="' + cls + '" title="' + (it.sync_note || '') + '">' + lbl + '</span>';
        } else if (it.sync_ok) {
          syncBadge = '<span class="badge-win">OK</span>';
        } else {
          syncBadge = '<span class="badge-loss" title="' + (it.sync_note || 'DB·실계좌 수량 불일치') + '">주의</span>';
        }
      }
      const codeEsc = String(it.code).replace(/'/g, "\\'");
      const stratEsc = String(it.strategy || '').replace(/'/g, "\\'");
      const stratLbl = it.strategy ? '<code>' + it.strategy + '</code>' : '<span class="text-muted">—</span>';
      const sellBtn = it.can_sell
        ? `<button class="btn btn-sm btn-danger" style="font-size:11px"
              onclick="pfSell('${codeEsc}','${stratEsc}','${String(it.name).replace(/'/g, '')}',${it.db_qty})">
              시장가 전량</button>`
        : '<span class="text-muted" style="font-size:11px" title="active_trades 없음">매도불가</span>';
      tbody.insertAdjacentHTML('beforeend', `
        <tr>
          <td>${stratLbl}</td>
          <td>${it.name}<br><small class="text-muted">${it.code}</small></td>
          <td class="text-nowrap">${pfFmtBuyDate(it.buy_date)}</td>
          <td>${it.db_qty}</td>
          <td>${it.broker_qty == null ? '—' : it.broker_qty}</td>
          <td>${syncBadge}</td>
          <td>${it.buy_price > 0 ? fmtWon(it.buy_price) : '—'}</td>
          <td>${it.broker_avg_price != null && it.broker_avg_price > 0
              ? fmtWon(it.broker_avg_price)
              : '—'}</td>
          <td>${fmtWon(it.current_price)}</td>
          <td class="${pnlCls}">${it.pnl_pct >= 0 ? '+' : ''}${it.pnl_pct}%</td>
          <td>${fmtWon(it.eval_amt)}</td>
          <td>${sellBtn}</td>
        </tr>`);
  });
}

// 보유·매도 정렬 버튼 (다른 탭과 동일 UX) — pf-sort-btn 클릭 시 재조회 없이 정렬만 갱신
document.addEventListener('click', e => {
  const btn = e.target.closest('#pf_sort_bar .pf-sort-btn');
  if (!btn) return;
  _pfSort = btn.dataset.sort || 'pnl_desc';
  pfRenderTable();
});

async function pfSell(code, strategy, name, qty) {
  const msg =
    `[${strategy}] ${name}(${code})\n` +
    `DB 보유 ${qty}주를 시장가 전량 매도합니다.\n` +
    `OrderManager 매도 경로로 DB(trade_history) 동기화됩니다.\n\n` +
    `HTS 에서 이미 팔았다면 실계좌 0주일 수 있으며, 그 경우 유령잔고 정리만 됩니다.\n계속할까요?`;
  if (!confirm(msg)) return;
  showSpinner(true);
  try {
    const r = await fetch('/api/portfolio/sell', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ code, strategy }),
    });
    const d = await r.json();
    if (d.ok) {
      alert(
        `매도 접수 완료\nODNO: ${d.ord_no || '-'}\n` +
        `체결 ${d.filled_qty || '-'}주 @ ${Number(d.filled_avg_price || 0).toLocaleString()}원`
      );
      pfLoad(false);
    } else {
      alert('매도 실패: ' + (d.error || d.detail || 'unknown'));
    }
  } catch (e) {
    alert('매도 요청 오류: ' + e);
  } finally {
    showSpinner(false);
  }
}

/** 미등록 보유분 중 '봇 고아'만 전량 시장가 일괄매도 — 수동매수/보호목록은 제외 */
async function pfSellUntrackedAll() {
  if (!_pfMeta || !_pfMeta.with_broker) {
    alert('먼저 「📡 실계좌 대조」를 눌러 실계좌 잔고를 불러온 뒤 실행하세요.');
    return;
  }
  const untracked = (_pfItems || []).filter(x => x.untracked && (x.broker_qty || 0) > 0);
  // 봇 고아(origin==='bot')만 매도 대상, 수동(origin==='manual')은 보호
  const targets = untracked.filter(x => x.origin === 'bot');
  const manual = untracked.filter(x => x.origin !== 'bot');
  if (!targets.length) {
    alert(
      '매도 대상(봇 고아) 종목이 없습니다.' +
      (manual.length ? `\n수동매수 추정 ${manual.length}종목은 보호되어 제외됩니다.` : '')
    );
    return;
  }
  const totalEval = targets.reduce((s, x) => s + (Number(x.eval_amt) || 0), 0);
  const preview = targets.slice(0, 15)
    .map(x => ` · ${x.name}(${x.code}) ${x.broker_qty}주`).join('\n');
  const more = targets.length > 15 ? `\n …외 ${targets.length - 15}종목` : '';
  const manualNote = manual.length
    ? `\n\n🛡️ 보호(제외) ${manual.length}종목: 수동매수 추정/보호목록\n` +
      manual.slice(0, 10).map(x => ` · ${x.name}(${x.code})`).join('\n')
    : '';
  const msg =
    `⚠️ 봇 고아(미기록 체결) ${targets.length}종목을 시장가로 전량 매도합니다.\n` +
    `예상 평가금 합계: 약 ${Number(totalEval).toLocaleString()}원\n\n` +
    `${preview}${more}${manualNote}\n\n` +
    `※ 봇 관리분(active_trades)·수동매수분은 건드리지 않습니다.\n` +
    `※ 시장가 주문이라 슬리피지가 발생할 수 있습니다.\n계속할까요?`;
  if (!confirm(msg)) return;
  showSpinner(true);
  try {
    const r = await fetch('/api/portfolio/sell_untracked_all', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({}),
    });
    const d = await r.json();
    if (d.ok) {
      let out = `일괄매도 접수: 성공 ${d.sold_count || 0}건 / 실패 ${d.failed_count || 0}건`
        + (d.protected_count ? ` / 보호 ${d.protected_count}건` : '');
      if ((d.failed || []).length) {
        out += '\n\n[실패]\n' + d.failed.slice(0, 10)
          .map(f => ` · ${f.name}(${f.code}): ${f.error}`).join('\n');
      }
      if (d.msg) out = d.msg;
      alert(out);
      pfLoad(true); // 실계좌 재대조
    } else {
      alert('일괄매도 실패: ' + (d.error || 'unknown'));
    }
  } catch (e) {
    alert('일괄매도 요청 오류: ' + e);
  } finally {
    showSpinner(false);
  }
}

/** 봇 고아(active_trades 미기록) 수동 DB복구 — 매도 아님. 장마감 배치와 동일 로직 */
async function pfReconcileOrphans() {
  const msg =
    `봇이 매수했지만 active_trades 에 누락된 종목을 DB로 복구합니다.\n` +
    `(매도가 아니라 봇이 관리하도록 등록만 — 이후 손절/익절 신호가 작동)\n\n` +
    `※ 수동매수(주문기록 없음)·MANUAL_HOLD_CODES 종목은 제외됩니다.\n계속할까요?`;
  if (!confirm(msg)) return;
  showSpinner(true);
  try {
    const r = await fetch('/api/portfolio/reconcile_orphans', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({}),
    });
    const d = await r.json();
    if (d.ok) {
      let out = `봇고아 DB복구: ${d.reconciled_count || 0}종목 복구`
        + (d.failed_count ? ` / 실패 ${d.failed_count}건` : '')
        + `\n(제외: 수동 ${d.skipped_manual_count || 0} · 이미추적 ${d.skipped_tracked_count || 0} · 주문없음 ${d.skipped_no_order_count || 0})`;
      if ((d.reconciled || []).length) {
        out += '\n\n[복구]\n' + d.reconciled.slice(0, 12)
          .map(x => ` · ${x.name}(${x.code}) [${x.strategy}] ${x.qty}주`).join('\n');
      }
      if ((d.failed || []).length) {
        out += '\n\n[실패]\n' + d.failed.slice(0, 8)
          .map(f => ` · ${f.name}(${f.code}): ${f.error}`).join('\n');
      }
      alert(out);
      pfLoad(true); // 실계좌 재대조 (복구분이 봇추적으로 전환됨)
    } else {
      alert('고아복구 실패: ' + (d.error || 'unknown'));
    }
  } catch (e) {
    alert('고아복구 요청 오류: ' + e);
  } finally {
    showSpinner(false);
  }
}

// ────────────────────────────────────────────
// 날짜 기본값 설정 (KST = 한국장 기준, 브라우저 타임존과 무관)
// ────────────────────────────────────────────
function fmtKstDate(d) {
  return new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Seoul' }).format(d);
}
function kstTodayParts() {
  const iso = fmtKstDate(new Date());
  const [y, m, day] = iso.split('-').map(x => parseInt(x, 10));
  return { y, m, day, iso };
}
function kstMonthStartIso() {
  const t = kstTodayParts();
  return `${t.y}-${String(t.m).padStart(2, '0')}-01`;
}
function kstMonthAgoIso() {
  const t = kstTodayParts();
  let idx = t.y * 12 + (t.m - 1) - 1;
  const ny = Math.floor(idx / 12);
  const nm = (idx % 12) + 1;
  const lastDay = new Date(ny, nm, 0).getDate();
  const nd = Math.min(t.day, lastDay);
  return `${ny}-${String(nm).padStart(2, '0')}-${String(nd).padStart(2, '0')}`;
}
/** 현재 KST 시(0~23) */
function kstHourNow() {
  const parts = new Intl.DateTimeFormat('en-US', {
    timeZone: 'Asia/Seoul', hour: 'numeric', hour12: false,
  }).formatToParts(new Date());
  const h = parts.find(p => p.type === 'hour');
  return h ? (parseInt(h.value, 10) % 24) : 12;
}
function kstYesterdayIso() {
  return fmtKstDate(new Date(Date.now() - 24 * 3600 * 1000));
}
/** N일 전 KST 날짜 (백테 기본 시작일 등) */
function kstDaysAgoIso(n) {
  return fmtKstDate(new Date(Date.now() - n * 24 * 3600 * 1000));
}
/** 실거래 조회 기본 거래일 — 장 시작(09:00 KST) 전이면 전날, 그 외엔 당일 */
function kstTradingDayIso() {
  return kstHourNow() < 9 ? kstYesterdayIso() : kstTodayParts().iso;
}
function setDateVal(id, val) {
  const el = $(id);
  if (el && val) el.value = val;
}
function initDefaultDates() {
  const todayKst = kstTodayParts().iso;
  // 실거래 분석: 시작·종료 모두 당일(장 시작 전이면 전날) — 단일 거래일 기본 조회.
  //   과거를 보려면 사용자가 시작일만 앞으로 당기면 됨(달 변경 불필요).
  const tradingDayKst = kstTradingDayIso();
  setDateVal('act_start', tradingDayKst);
  setDateVal('act_end', tradingDayKst);
  // 백테 탭: 시작일 일주일 전 ~ 오늘 (KST)
  const weekAgoKst = kstDaysAgoIso(7);
  setDateVal('bt_start', weekAgoKst);
  setDateVal('bt_end', todayKst);
  setDateVal('tl_start', weekAgoKst);
  setDateVal('tl_end', todayKst);
  setDateVal('db_start', weekAgoKst);
  setDateVal('db_end', todayKst);
  setDateVal('db_fetch_start', weekAgoKst);
  setDateVal('bo_start', weekAgoKst);
  setDateVal('bo_end', todayKst);
  setDateVal('rb_start', weekAgoKst);
  setDateVal('rb_end', todayKst);
  setDateVal('mom_start', weekAgoKst);
  setDateVal('mom_end', todayKst);
  setDateVal('hd_start', todayKst);
  setDateVal('hd_end', todayKst);
  setDateVal('hd_fetch_start', todayKst);
  const lr = $('lastRefresh');
  if (lr) lr.textContent = '업데이트: ' + new Date().toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' });
}

(function() {
  initDefaultDates();
  const hdFetch = $('hd_fetch_start');
  if (hdFetch) {
    hdFetch.addEventListener('change', function() {
      if (this.value) setDateVal('hd_start', this.value);
    });
  }

  // ── DB config_scalp / config_momentum / config_short → 스캘핑·꼬리·모멘텀 초기값 ──
  // DB 값이 있으면 반영, null이면 HTML에 하드코딩된 기본값 그대로 유지
  fetch('/api/env/params')
    .then(r => r.json())
    .then(d => {
      const set = (id, val) => { if (val !== null && val !== undefined) $(id).value = val; };
      const setM = set; // 돌파 탭 필드 (setM 미정의 시 ReferenceError → catch로 HTML 기본값 21 고정됨)
      // 스캘핑 탭
      const s = d.scalp || {};
      set('bt_rsi_oversold', s.rsi_oversold);
      set('bt_rsi_overbought', s.rsi_overbought);
      set('bt_sl',           s.sl_pct);
      set('bt_tp',           s.tp_pct);
      set('bt_tp_max',       s.tp_max_pct);
      set('bt_drop',         s.drop_rate);
      set('bt_smin',         s.shoulder_min_high);
      set('bt_scut',         s.shoulder_cut_pct);
      set('bt_cooldown',     s.cooldown_min);
      if (s.slot_money) set('bt_slot', s.slot_money);
      if (s.high_chase_thr != null) set('bt_high_chase', s.high_chase_thr);
      if (s.max_daily_chg != null) set('bt_max_daily_chg', s.max_daily_chg);
      if (s.min_price != null) set('bt_min_price', s.min_price);
      if (s.max_loss_krw != null) set('bt_max_loss_krw', s.max_loss_krw);
      if (s.min_margin != null) set('bt_min_margin', s.min_margin);
      if (s.use_defense_filters !== undefined && $('bt_use_defense')) {
        const v = String(s.use_defense_filters).trim().toLowerCase();
        $('bt_use_defense').checked = (v === '1' || v === 'true' || v === 'y' || v === 'yes' || v === 'on');
      }
      if (s.use_macd_cross !== undefined && $('bt_use_macd')) {
        const v = String(s.use_macd_cross).trim().toLowerCase();
        $('bt_use_macd').checked = (v === '1' || v === 'true' || v === 'y' || v === 'yes' || v === 'on');
      }

      // 꼬리잡기 탭 — config_short + env_config (실매·파라서치와 동일)
      fillTailFormFromApi(d.tail || {});

      // 더블BB 탭
      fillDbBandFormFromApi(d.dbband || {});

      // 모멘텀 탭 — config_momentum + env_config
      fillMomentumFormFromApi(d.momentum || {});

      // 돌파 탭 — DB(env_config BREAKOUT_*) = 백테 API _bo_defaults_from_db 와 동일
      const b = d.breakout || {};
      setM('bo_lookback', b.lookback_min);
      setM('bo_vol_win', b.vol_window);
      setM('bo_vol_mult', b.vol_mult);
      setM('bo_min_turnover', b.min_turnover_1m_pct);
      setM('bo_prev_min', b.prev_chg_min);
      setM('bo_prev_max', b.prev_chg_max);
      if (b.entry_mode && $('bo_entry_mode')) {
        $('bo_entry_mode').value = String(b.entry_mode).toLowerCase();
      }
      setM('bo_intrabar_slip', b.intrabar_slippage_pct);
      if (b.sl_mode && $('bo_sl_mode')) {
        $('bo_sl_mode').value = String(b.sl_mode).toLowerCase();
      }
      setM('bo_sl', b.sl_pct);
      if (b.atr_period != null) setM('bo_atr_period', b.atr_period);
      if (b.atr_sl_mult != null) setM('bo_atr_sl_mult', b.atr_sl_mult);
      if (b.atr_sl_min_pct != null) setM('bo_atr_sl_min', b.atr_sl_min_pct);
      if (b.atr_sl_max_pct != null) setM('bo_atr_sl_max', b.atr_sl_max_pct);
      boSyncSlModeColors();
      setM('bo_tp', b.tp_pct);
      setM('bo_trail', b.trail_pct);
      if (b.trail_arm_pct != null) setM('bo_trail_arm', b.trail_arm_pct);
      setM('bo_shoulder_smin', b.shoulder_min_high_pct);
      setM('bo_shoulder_scut', b.shoulder_cut_pct);
      setM('bo_time_start', b.time_start_hm);
      setM('bo_time_end', b.time_end_hm);
      if (b.eod_enabled !== undefined && $('bo_eod_enabled')) {
        $('bo_eod_enabled').checked = !!b.eod_enabled;
      }
      if (b.eod_hm != null) setM('bo_eod_hm', b.eod_hm);
      if (b.max_hold_bars != null) setM('bo_max_hold', b.max_hold_bars);
      setM('bo_max_daily', b.max_daily);
      setM('bo_cooldown', b.cooldown_min);
      setM('bo_max_chg', b.max_daily_chg);
      setM('bo_min_price', b.min_price);
      if (b.confirm_margin_pct != null) setM('bo_confirm_margin', b.confirm_margin_pct);
      if (b.body_min_pct != null) setM('bo_body_min', b.body_min_pct);
      setM('bo_max_loss', b.max_loss_krw);
      if (b.use_ema_filter !== undefined && $('bo_use_ema_f')) {
        $('bo_use_ema_f').checked = !!b.use_ema_filter;
      }
      if (b.ema_fast_period != null) setM('bo_ema_fast', b.ema_fast_period);
      if (b.ema_slow_period != null) setM('bo_ema_slow', b.ema_slow_period);
      if (b.ob_filter_enabled !== undefined && $('bo_ob_filter')) {
        $('bo_ob_filter').checked = !!b.ob_filter_enabled;
      }
      if (b.max_spread_pct != null && $('bo_max_spread_pct')) {
        $('bo_max_spread_pct').value = b.max_spread_pct;
      }
      if (b.pg_filter_enabled !== undefined && $('bo_pg_filter')) {
        $('bo_pg_filter').checked = !!b.pg_filter_enabled;
      }
      setM('bo_slot', b.slot_money);
      if (b.max_stocks != null) setM('bo_max_stocks', b.max_stocks);
      if (b.total_budget_krw != null) setM('bo_total_budget', b.total_budget_krw);
      // 당일 누적손익 다단 트레일(꼬리와 동일) 현재값·방식·프리셋 복원
      if ($('bo_daily_trail_tiers')) {
        $('bo_daily_trail_tiers').value = (b.daily_trail_tiers != null ? String(b.daily_trail_tiers) : '');
      }
      if (b.daily_profit_mode && $('bo_daily_profit_mode')) {
        $('bo_daily_profit_mode').value = b.daily_profit_mode;
      }
      btFillPresetOptions('bo_daily_trail_preset', b.daily_trail_presets);

      const rb = d.range_break || {};
      setM('rb_box_lb', rb.box_lookback_min);
      setM('rb_box_max_w', rb.box_max_width_pct);
      setM('rb_setup_vol', rb.setup_vol_max_mult);
      setM('rb_bear_min', rb.setup_bear_bars_min);
      setM('rb_vol_win', rb.vol_window);
      setM('rb_vol_mult', rb.vol_mult);
      setM('rb_sl', rb.sl_pct);
      setM('rb_tp', rb.tp_pct);
      setM('rb_trail', rb.trail_pct);
      if (rb.trail_arm_pct != null) setM('rb_trail_arm', rb.trail_arm_pct);
      setM('rb_shoulder_smin', rb.shoulder_min_high_pct);
      setM('rb_shoulder_scut', rb.shoulder_cut_pct);
      setM('rb_time_start', rb.time_start_hm);
      setM('rb_time_end', rb.time_end_hm);
      setM('rb_max_daily', rb.max_daily);
      setM('rb_cooldown', rb.cooldown_min);
      setM('rb_max_chg', rb.max_daily_chg);
      setM('rb_min_price', rb.min_price);
      setM('rb_max_loss', rb.max_loss_krw);
      if (rb.use_high_chase_filter !== undefined && $('rb_use_high_chase')) {
        $('rb_use_high_chase').checked = !!rb.use_high_chase_filter;
      }
      setM('rb_slot', rb.slot_money);
      if (rb.max_stocks != null) setM('rb_max_stocks', rb.max_stocks);
      if (rb.total_budget_krw != null) setM('rb_total_budget', rb.total_budget_krw);
    })
    .catch(() => { /* DB 연결 실패 시 HTML 기본값 유지 */ });

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

})();

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

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

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

  $('a_pnl').textContent = fmtKrw(s.total_pnl);
  colorPnl($('a_pnl'), s.total_pnl);

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

  $('a_mdd').textContent  = '-' + fmtWon(s.max_drawdown) + '원';
  $('a_hold').textContent = s.avg_hold_min + '분';
  const openCnt = Number((d.meta || {}).open_count || 0);
  const closedCnt = Number((d.meta || {}).closed_count || 0);
  const hint = $('act_table_hint');
  if (hint) {
    hint.textContent = `(청산 ${closedCnt}건 · 보유 ${openCnt}건 — 상단 요약·차트는 청산만)`;
  }
  const actCtx = $('act_trade_context');
  if (actCtx) {
    const tb = Number(p.total_budget_krw || 0);
    const peak = Number(s.peak_cum_pnl || 0);
    const peakAt = s.peak_cum_at || '';
    let line = `📋 실매 trade_history · 한도 ${tb > 0 ? fmtWon(tb) : '—'}원`;
    if (peak > 0 && peakAt) {
      line += ` | 장중 누적 최고 <b style="color:var(--green)">+${fmtWon(peak)}원</b> (${peakAt})`;
    }
    line += ` | 최종 누적 <b>${fmtKrw(s.total_pnl)}</b>`;
    actCtx.innerHTML = line;
    actCtx.style.display = 'block';
  }
  const note = $('act_filter_note');
  if (note) {
    const parts = [];
    if (openCnt > 0) {
      parts.push(`📌 보유중 <b>${openCnt}</b>건 — 매수만 된 포지션(노란 행), 매도 후 trade_history 로 이동`);
    }
    if (filtered > 0) {
      parts.push(`⚠️ 집계 제외: 0원 강제정리 <b>${filtered}</b>건`);
    }
    if (s.peak_cum_pnl > 0 && s.total_pnl < s.peak_cum_pnl) {
      parts.push(
        `💡 장중 누적이 <b>+${fmtKrw(s.peak_cum_pnl)}</b>까지 갔다가 이후 청산으로 <b>${fmtKrw(s.total_pnl)}</b> — `
        + `누적손익 열은 <b>매도 완료 순</b> 합계입니다.`,
      );
    }
    if (parts.length) {
      note.style.display = '';
      note.innerHTML = parts.join('<br>');
    } else {
      note.style.display = 'none';
      note.innerHTML = '';
    }
  }
  if (filtered > 0) {
    console.log(`[실거래 분석] 0원 강제정리 ${filtered}건 제외 후 집계`);
  }

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

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

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

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

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

  renderVirtualTrades('act_tbody', d.trades || [], {
    showDebug: true,
    showCumulative: true,
    totalBudget: Number(p.total_budget_krw || 0),
  });
}

// ────────────────────────────────────────────
// 돌파 백테스트 실행
// ────────────────────────────────────────────
function runBreakoutBacktest() {
  let start = $('bo_start')?.value || '';
  let end = $('bo_end')?.value || '';
  if (!start) {
    start = kstMonthStartIso();
    setDateVal('bo_start', start);
  }
  if (!end) {
    end = kstTodayParts().iso;
    setDateVal('bo_end', end);
  }
  if (end && start && end < start) {
    alert('종료일이 시작일보다 앞섭니다. 날짜를 확인하세요.');
    return;
  }
  const params = {
    start,
    end,
    lookback_min:  $('bo_lookback').value,
    vol_window:    $('bo_vol_win').value,
    vol_mult:      $('bo_vol_mult').value,
    min_turnover_1m_pct: $('bo_min_turnover')?.value || '0.05',
    prev_chg_min:  $('bo_prev_min').value,
    prev_chg_max:  $('bo_prev_max').value,
    entry_mode:    $('bo_entry_mode')?.value || 'intrabar',
    intrabar_slippage_pct: $('bo_intrabar_slip')?.value || '0',
    sl_mode: ($('bo_sl_mode') && $('bo_sl_mode').value) || 'fixed',
    sl_pct:        $('bo_sl').value,
    atr_period:    $('bo_atr_period')?.value || '14',
    atr_sl_mult:   $('bo_atr_sl_mult')?.value || '2.0',
    atr_sl_min_pct: $('bo_atr_sl_min')?.value || '0.8',
    atr_sl_max_pct: $('bo_atr_sl_max')?.value || '6.0',
    tp_pct:        $('bo_tp').value,
    trail_pct:     $('bo_trail').value,
    trail_arm_pct: $('bo_trail_arm')?.value || '0',
    shoulder_min_high_pct: $('bo_shoulder_smin').value,
    shoulder_cut_pct: $('bo_shoulder_scut').value,
    time_start_hm: $('bo_time_start').value,
    time_end_hm:   $('bo_time_end').value,
    max_daily:     $('bo_max_daily').value,
    cooldown_min:  $('bo_cooldown').value,
    max_daily_chg: $('bo_max_chg').value,
    min_price:     $('bo_min_price').value,
    confirm_margin_pct: $('bo_confirm_margin')?.value || '0',
    body_min_pct:  $('bo_body_min')?.value || '0',
    use_ema_filter: $('bo_use_ema_f')?.checked ? 1 : 0,
    ema_fast_period: $('bo_ema_fast')?.value || '9',
    ema_slow_period: $('bo_ema_slow')?.value || '21',
    max_loss_krw:  $('bo_max_loss').value,
    slot_money:    $('bo_slot').value,
    max_stocks:    $('bo_max_stocks')?.value || '20',
    total_budget_krw: $('bo_total_budget')?.value || '0',
    universe:      $('bo_use_univ_history')?.checked ? 'history' : 'all',
    ob_filter:     $('bo_ob_filter')?.checked ? 1 : 0,
    pg_filter:     $('bo_pg_filter')?.checked ? 1 : 0,
    max_spread_pct: $('bo_max_spread_pct')?.value,
    daily_trail_tiers: ($('bo_daily_trail_tiers') && $('bo_daily_trail_tiers').value.trim()) || '',
    daily_trail_drop_pct: $('bo_daily_trail_drop')?.value || '0',
    daily_trail_arm_krw: $('bo_daily_trail_arm')?.value || '0',
    daily_profit_mode: ($('bo_daily_profit_mode') && $('bo_daily_profit_mode').value) || 'trailing',
    eod_enabled: $('bo_eod_enabled')?.checked ? 1 : 0,
    eod_hm: ($('bo_eod_hm') && $('bo_eod_hm').value.trim()) || '15:15',
  };
  const qs = new URLSearchParams(params).toString();
  showSpinner(true);
  fetch('/api/backtest/breakout?' + qs)
    .then(async r => {
      const d = await r.json().catch(() => ({}));
      if (!r.ok) throw new Error(d.error || ('HTTP ' + r.status));
      return d;
    })
    .then(d => { showSpinner(false); renderBreakoutBacktest(d); })
    .catch(err => { showSpinner(false); alert('백테스트 오류: ' + err); });
}

function saveBreakoutConfig() {
  const body = {
    lookback_min:  parseInt($('bo_lookback').value, 10),
    vol_window:    parseInt($('bo_vol_win').value, 10),
    vol_mult:      parseFloat($('bo_vol_mult').value),
    min_turnover_1m_pct: parseFloat($('bo_min_turnover')?.value || '0.05'),
    prev_chg_min:  parseFloat($('bo_prev_min').value),
    prev_chg_max:  parseFloat($('bo_prev_max').value),
    entry_mode:    $('bo_entry_mode')?.value || 'intrabar',
    intrabar_slippage_pct: parseFloat($('bo_intrabar_slip')?.value || '0'),
    sl_mode: ($('bo_sl_mode') && $('bo_sl_mode').value) || 'fixed',
    sl_pct:        parseFloat($('bo_sl').value),
    atr_period:    parseInt($('bo_atr_period')?.value || '14', 10),
    atr_sl_mult:   parseFloat($('bo_atr_sl_mult')?.value || '2.0'),
    atr_sl_min_pct: parseFloat($('bo_atr_sl_min')?.value || '0.8'),
    atr_sl_max_pct: parseFloat($('bo_atr_sl_max')?.value || '6.0'),
    tp_pct:        parseFloat($('bo_tp').value),
    trail_pct:     parseFloat($('bo_trail').value),
    trail_arm_pct: parseFloat($('bo_trail_arm')?.value || '0'),
    shoulder_min_high_pct: parseFloat($('bo_shoulder_smin').value),
    shoulder_cut_pct: parseFloat($('bo_shoulder_scut').value),
    time_start_hm: parseInt($('bo_time_start').value, 10),
    time_end_hm:   parseInt($('bo_time_end').value, 10),
    eod_enabled: !!($('bo_eod_enabled')?.checked),
    eod_hm: ($('bo_eod_hm') && $('bo_eod_hm').value.trim()) || '15:15',
    max_hold_bars: parseInt($('bo_max_hold')?.value || '0', 10),
    max_daily:     parseInt($('bo_max_daily').value, 10),
    cooldown_min:  parseFloat($('bo_cooldown').value),
    max_daily_chg: parseFloat($('bo_max_chg').value),
    min_price:     parseFloat($('bo_min_price').value),
    confirm_margin_pct: parseFloat($('bo_confirm_margin')?.value || '0'),
    body_min_pct:  parseFloat($('bo_body_min')?.value || '0'),
    use_ema_filter: !!($('bo_use_ema_f')?.checked),
    ema_fast_period: parseInt($('bo_ema_fast')?.value || '9', 10),
    ema_slow_period: parseInt($('bo_ema_slow')?.value || '21', 10),
    max_loss_krw:  parseInt($('bo_max_loss').value, 10),
    slot_money:    parseInt($('bo_slot').value, 10),
    max_stocks:    parseInt($('bo_max_stocks')?.value || '20', 10),
    total_budget_krw: parseInt($('bo_total_budget')?.value || '0', 10),
    ob_filter: !!($('bo_ob_filter')?.checked),
    pg_filter: !!($('bo_pg_filter')?.checked),
    daily_trail_tiers: ($('bo_daily_trail_tiers') && $('bo_daily_trail_tiers').value.trim()) || '',
    daily_profit_mode: ($('bo_daily_profit_mode') && $('bo_daily_profit_mode').value) || 'trailing',
  };
  if (!confirm('💾 돌파 봇(BREAKOUT_* env)에 현재 탭 값을 저장할까요?\n실행 중이면 다음 루프부터 반영됩니다.')) return;
  fetch('/api/backtest/breakout/save_config', {
    method: 'POST', headers: {'Content-Type': 'application/json'},
    body: JSON.stringify(body),
  }).then(r => r.json()).then(d => {
    if (d.error) { alert('❌ ' + d.error); return; }
    btAddPresetOption('bo_daily_trail_preset', $('bo_daily_trail_tiers') && $('bo_daily_trail_tiers').value);
    const byTbl = d.saved_by_table || {};
    const tblLines = Object.entries(byTbl).map(([t, ks]) => `  ${t}: ${ks.join(', ')}`).join('\n');
    alert(`✅ 저장 완료 (env_id: ${d.env_id})\n테이블:\n${tblLines || d.saved_keys?.join(', ')}`);
  }).catch(e => alert('오류: ' + e));
}

function renderBreakoutBacktest(d) {
  if (!d || d.error) {
    alert('❌ ' + (d && d.error ? d.error : '백테스트 응답 없음'));
    return;
  }
  if (!d.summary) { alert('백테스트 응답 오류 (summary 없음)'); return; }
  const s = d.summary;
  const p = d.params || {};

  const boRa = $('bo_result_area');
  if (boRa) boRa.style.display = 'block';
  const boPb = $('bo_params_bar');
  if (boPb) boPb.style.display = 'block';

  const universeLabel = formatUniverseLabel(p);
  const tr = (v) => (typeof v === 'number' ? Number(v).toFixed(1) : (v != null ? v : '—'));
  const periodHint = (p.start && p.end) ? ` | ${p.start}~${p.end}` : '';
  const summaryLine =
    `<span class="badge ${p.universe_source === 'history' ? 'badge-info' : 'badge-secondary'}">${universeLabel}</span> ` +
    `진입 ${(p.entry_mode || 'intrabar').toUpperCase()} | ` +
    `Lookback ${p.lookback_min}분 | 회전율≥${tr(p.min_turnover_1m_pct)}%` +
    (Number(p.vol_mult) > 0 ? ` · VolWin${p.vol_window}분×${tr(p.vol_mult)}배` : '') + ` | ` +
    `직전봉 ${tr(p.prev_chg_min)}~${tr(p.prev_chg_max)}% | ` +
    ((Number(p.confirm_margin_pct) > 0 || Number(p.body_min_pct) > 0)
      ? `가짜돌파필터(여유${tr(p.confirm_margin_pct||0)}%·몸통${tr(p.body_min_pct||0)}%) | ` : ``) +
    `EMA필터 ${p.use_ema_filter ? `ON(${p.ema_fast_period}/${p.ema_slow_period})` : 'OFF'} | ` +
    `손절-${tr(p.sl_pct)}% 익절+${tr(p.tp_pct)}% 트레일${tr(p.trail_pct)}%` +
    (Number(p.trail_arm_pct) > 0 ? `(무장${tr(p.trail_arm_pct)}%)` : '') + ` | ` +
    `${p.time_window}${periodHint} | 일${p.max_daily}회 쿨다운${p.cooldown_min}분 | ` +
    `1회 ${Number(p.slot_money||0).toLocaleString()}원 · 동시${p.max_stocks||'?'}종 · 한도 ${Number(p.total_budget_krw||0).toLocaleString()}원 | 종목수 ${p.codes_analyzed || 0}개`;
  let barHtml = summaryLine;
  if (s.budget_warning) {
    barHtml += `<div class="mt-1" style="color:#e3b341;font-size:12px">💰 ${s.budget_warning}</div>`;
  }
  const tickMeta = s.tick_backtest || {};
  const buySrc = s.backtest_buy_source || '';
  if (buySrc === 'ws_ticks' || buySrc === 'ohlc_fallback' || tickMeta.tick_bar_coverage_pct != null) {
    const cov = tickMeta.tick_bar_coverage_pct != null ? tickMeta.tick_bar_coverage_pct : '—';
    const rows = tickMeta.ws_tick_rows_loaded != null ? Number(tickMeta.ws_tick_rows_loaded).toLocaleString() : '—';
    const srcLabel = buySrc === 'ws_ticks' ? '틱DB(ws_ticks)' : (buySrc === 'ohlc_fallback' ? 'OHLC high 폴백' : buySrc);
    const tickColor = buySrc === 'ws_ticks' ? '#3fb950' : '#e3b341';
    barHtml += `<div class="mt-1" style="color:${tickColor};font-size:12px">📊 매수재생 ${srcLabel} | 틱 ${rows}건 · 분봉커버 ${cov}%</div>`;
  }
  if ((s.total_trades || 0) === 0) {
    const zmsg = s.budget_warning
      ? `거래 0건 — ${s.budget_warning}`
      : '거래 0건 — 기간·유니버스·진입조건을 확인하세요. (1회투자 > 총한도 이면 체결 불가)';
    barHtml += `<div class="mt-2 p-2 rounded" style="color:#f85149;font-size:13px;border:1px solid #f85149;background:rgba(248,81,73,.08)">⚠️ ${zmsg}</div>`;
  }
  if (boPb) boPb.innerHTML = barHtml;
  const boCtx = $('bo_trade_context');
  if (boCtx) {
    boCtx.innerHTML = '📋 ' + summaryLine;
    boCtx.style.display = 'block';
  }

  const setTxt = (id, txt) => { const el = $(id); if (el) el.textContent = txt; };
  setTxt('bo_total', (s.total_trades||0) + '건');
  setTxt('bo_winrate', (s.win_rate||0) + '%');
  colorPnl($('bo_winrate'), (s.win_rate||0) - 50);

  setTxt('bo_pnl', fmtKrw(s.total_pnl));
  colorPnl($('bo_pnl'), s.total_pnl);

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

  setTxt('bo_mdd', '-' + fmtWon(s.max_drawdown) + '원');
  setTxt('bo_hold', (s.avg_hold_min||0) + '분');

  const boSign = v => (v > 0 ? '+' : '');
  setTxt('bo_bot_pct', boSign(s.bot_pct || 0) + (s.bot_pct || 0) + '%');
  colorPnl($('bo_bot_pct'), s.bot_pct || 0);
  setTxt('bo_daily_avg_pct', boSign(s.daily_avg_pct || 0) + (s.daily_avg_pct || 0) + '%');
  colorPnl($('bo_daily_avg_pct'), s.daily_avg_pct || 0);

  const winbar = $('bo_winbar_card');
  if ((s.total_trades||0) > 0) {
    if (winbar) winbar.style.display = 'block';
    const wr = s.win_rate||0;
    setTxt('bo_win_label', `🟢 승 ${s.win_trades||0}건 (${wr}%)`);
    setTxt('bo_loss_label', `🔴 패 ${s.loss_trades||0}건 (${(100-wr).toFixed(1)}%)`);
    const rg = $('bo_ratio_g'); if (rg) rg.style.width = wr + '%';
    const rr = $('bo_ratio_r'); if (rr) rr.style.width = (100-wr) + '%';
  } else if (winbar) {
    winbar.style.display = 'none';
  }

  try {
    if (d.equity && d.equity.length) {
      lineChart('bo_equity_chart',
        d.equity.map(e => e.date),
        d.equity.map(e => e.cum_pnl),
        '가상누적손익', '#3fb950');
    } else {
      destroyChart('bo_equity_chart');
    }

    const rKeys = Object.keys(d.reasons || {});
    if (rKeys.length) {
      doughnutChart('bo_reason_chart', rKeys, rKeys.map(k => d.reasons[k]));
    } else {
      destroyChart('bo_reason_chart');
    }

    if (d.daily && d.daily.length) {
      barChart('bo_daily_chart',
        d.daily.map(e => e.date.slice(5)),
        d.daily.map(e => e.pnl));
    } else {
      destroyChart('bo_daily_chart');
    }
  } catch (chartErr) {
    console.error('bo chart render', chartErr);
  }

  renderVirtualTrades('bo_tbody', d.trades || []);
  if (boRa) boRa.scrollIntoView({ behavior: 'smooth', block: 'start' });
}

function runRangeBreakBacktest() {
  let start = $('rb_start')?.value || '';
  let end = $('rb_end')?.value || '';
  if (!start) {
    start = kstMonthStartIso();
    setDateVal('rb_start', start);
  }
  if (!end) {
    end = kstTodayParts().iso;
    setDateVal('rb_end', end);
  }
  if (end && start && end < start) {
    alert('종료일이 시작일보다 앞섭니다.');
    return;
  }
  const params = {
    start, end,
    box_lookback_min: $('rb_box_lb').value,
    box_max_width_pct: $('rb_box_max_w').value,
    setup_vol_max_mult: $('rb_setup_vol').value,
    setup_bear_bars_min: $('rb_bear_min').value,
    vol_window: $('rb_vol_win').value,
    vol_mult: $('rb_vol_mult').value,
    sl_pct: $('rb_sl').value,
    tp_pct: $('rb_tp').value,
    trail_pct: $('rb_trail').value,
    trail_arm_pct: $('rb_trail_arm')?.value || '0',
    shoulder_min_high_pct: $('rb_shoulder_smin').value,
    shoulder_cut_pct: $('rb_shoulder_scut').value,
    time_start_hm: $('rb_time_start').value,
    time_end_hm: $('rb_time_end').value,
    max_daily: $('rb_max_daily').value,
    cooldown_min: $('rb_cooldown').value,
    max_daily_chg: $('rb_max_chg').value,
    min_price: $('rb_min_price').value,
    max_loss_krw: $('rb_max_loss').value,
    slot_money: $('rb_slot').value,
    max_stocks: $('rb_max_stocks')?.value || '20',
    total_budget_krw: $('rb_total_budget')?.value || '0',
    use_high_chase_filter: $('rb_use_high_chase')?.checked ? 1 : 0,
    universe: $('rb_use_univ_history')?.checked ? 'history' : 'all',
  };
  showSpinner(true);
  fetch('/api/backtest/range_break?' + new URLSearchParams(params).toString())
    .then(async r => {
      const d = await r.json().catch(() => ({}));
      if (!r.ok) throw new Error(d.error || ('HTTP ' + r.status));
      return d;
    })
    .then(d => { showSpinner(false); renderRangeBreakBacktest(d); })
    .catch(err => { showSpinner(false); alert('백테스트 오류: ' + err); });
}

function saveRangeBreakConfig() {
  const body = {
    box_lookback_min: parseInt($('rb_box_lb').value, 10),
    box_max_width_pct: parseFloat($('rb_box_max_w').value),
    setup_vol_max_mult: parseFloat($('rb_setup_vol').value),
    setup_bear_bars_min: parseInt($('rb_bear_min').value, 10),
    vol_window: parseInt($('rb_vol_win').value, 10),
    vol_mult: parseFloat($('rb_vol_mult').value),
    sl_pct: parseFloat($('rb_sl').value),
    tp_pct: parseFloat($('rb_tp').value),
    trail_pct: parseFloat($('rb_trail').value),
    trail_arm_pct: parseFloat($('rb_trail_arm')?.value || '0'),
    shoulder_min_high_pct: parseFloat($('rb_shoulder_smin').value),
    shoulder_cut_pct: parseFloat($('rb_shoulder_scut').value),
    time_start_hm: parseInt($('rb_time_start').value, 10),
    time_end_hm: parseInt($('rb_time_end').value, 10),
    max_daily: parseInt($('rb_max_daily').value, 10),
    cooldown_min: parseFloat($('rb_cooldown').value),
    max_daily_chg: parseFloat($('rb_max_chg').value),
    min_price: parseFloat($('rb_min_price').value),
    max_loss_krw: parseInt($('rb_max_loss').value, 10),
    use_high_chase_filter: !!($('rb_use_high_chase')?.checked),
    slot_money: parseInt($('rb_slot').value, 10),
    max_stocks: parseInt($('rb_max_stocks')?.value || '20', 10),
    total_budget_krw: parseInt($('rb_total_budget')?.value || '0', 10),
  };
  if (!confirm('💾 박스권돌파 봇(RANGE_BREAK_* env)에 저장할까요?')) return;
  fetch('/api/backtest/range_break/save_config', {
    method: 'POST', headers: {'Content-Type': 'application/json'},
    body: JSON.stringify(body),
  }).then(r => r.json()).then(d => {
    if (d.error) { alert('❌ ' + d.error); return; }
    alert('✅ 저장 완료 (env_id: ' + d.env_id + ')');
  }).catch(e => alert('오류: ' + e));
}

function renderRangeBreakBacktest(d) {
  if (!d || d.error) {
    alert('❌ ' + (d && d.error ? d.error : '응답 없음'));
    return;
  }
  if (!d.summary) { alert('summary 없음'); return; }
  const s = d.summary;
  const p = d.params || {};
  const ra = $('rb_result_area');
  if (ra) ra.style.display = 'block';
  const pb = $('rb_params_bar');
  if (pb) {
    pb.style.display = 'block';
    pb.innerHTML =
      `박스 ${p.box_lookback_min}분 · 폭≤${p.box_max_width_pct}% · vol×${p.vol_mult} ` +
      `| ${p.time_window} | 손절-${p.sl_pct}% 익절+${p.tp_pct}% | 종목 ${p.codes_analyzed || 0}개`;
  }
  const setTxt = (id, txt) => { const el = $(id); if (el) el.textContent = txt; };
  setTxt('rb_total', (s.total_trades || 0) + '건');
  setTxt('rb_winrate', (s.win_rate || 0) + '%');
  colorPnl($('rb_winrate'), (s.win_rate || 0) - 50);
  setTxt('rb_pnl', fmtKrw(s.total_pnl));
  colorPnl($('rb_pnl'), s.total_pnl);
  setTxt('rb_pf', (s.profit_factor || 0) >= 999 ? '∞' : String(s.profit_factor || 0));
  setTxt('rb_mdd', '-' + fmtWon(s.max_drawdown) + '원');
  setTxt('rb_hold', (s.avg_hold_min || 0) + '분');
  try {
    if (d.equity && d.equity.length) {
      lineChart('rb_equity_chart', d.equity.map(e => e.date), d.equity.map(e => e.cum_pnl), '누적손익', '#58a6ff');
    } else destroyChart('rb_equity_chart');
    const rKeys = Object.keys(d.reasons || {});
    if (rKeys.length) doughnutChart('rb_reason_chart', rKeys, rKeys.map(k => d.reasons[k]));
    else destroyChart('rb_reason_chart');
    if (d.daily && d.daily.length) {
      barChart('rb_daily_chart', d.daily.map(e => e.date.slice(5)), d.daily.map(e => e.pnl));
    } else destroyChart('rb_daily_chart');
  } catch (e) { console.error('rb chart', e); }
  renderVirtualTrades('rb_tbody', d.trades || []);
  if (ra) ra.scrollIntoView({ behavior: 'smooth', block: 'start' });
}

//   - 백엔드 API: /api/backtest/momentum (momentum_engine 전용)
//   - 탐색결과: /api/backtest/momentum/search_results · apply_search
// ============================================================================
function runMomentumBacktest() {
  try {
    showSpinner(true);
    let start = $('mom_start')?.value || '';
    let end = $('mom_end')?.value || '';
    if (!start) {
      start = kstMonthStartIso();
      setDateVal('mom_start', start);
    }
    if (!end) {
      end = kstTodayParts().iso;
      setDateVal('mom_end', end);
    }
    if (end && start && end < start) {
      showSpinner(false);
      alert('종료일이 시작일보다 앞섭니다. 날짜를 확인하세요.');
      return;
    }
    const v = id => { const el = $(id); return el ? el.value : ''; };
    const slotN = parseFloat(v('mom_slot_money')) || 0;
    const budgetN = parseFloat(v('mom_total_budget')) || 0;
    if (budgetN > 0 && slotN > 0 && budgetN < slotN * 0.9) {
      showSpinner(false);
      alert(
        `⚠️ 총운용한도(${budgetN.toLocaleString()}원) < 1회투자(${slotN.toLocaleString()}원)\n` +
        '→ 1주도 살 수 없어 거래 0건입니다.\n\n' +
        '총운용한도를 올리거나 1회투자금을 줄이세요.'
      );
      return;
    }
    const params = {
      start,
      end,
      sl_pct:         v('mom_sl'),
      tp_pct:         v('mom_tp'),
      tp_max_pct:     v('mom_tp_max'),
      slot_money:     v('mom_slot_money'),
      slots:          v('mom_slots'),
      total_budget_krw: v('mom_total_budget') || '0',
      cooldown_min:   v('mom_cooldown'),
      shoulder_min_high: v('mom_smin'),
      shoulder_cut_pct:  v('mom_scut'),
      trail_pct:      v('mom_trail'),
      trail_arm_pct:  v('mom_trail_arm'),
      max_hold_bars:  v('mom_max_hold'),
      ratchet_tiers:  v('mom_ratchet'),
      time_start:     v('mom_time_start'),
      time_end:       1530,
      max_daily:      v('mom_max_daily'),
      high_chase_thr: v('mom_high_chase'),
      max_daily_chg:  v('mom_max_daily_chg'),
      min_price:      v('mom_min_price'),
      max_loss_krw:   v('mom_max_loss_krw'),
      min_margin:     v('mom_min_margin'),
      use_defense_filters: $('mom_use_defense')?.checked ? 1 : 0,
      ob_filter: $('mom_ob_filter')?.checked ? 1 : 0,
      pg_filter: $('mom_pg_filter')?.checked ? 1 : 0,
      max_spread_pct: v('mom_max_spread_pct'),
      use_high_chase_filter: $('mom_use_high_chase_f')?.checked ? 1 : 0,
      use_daily_range_filter: $('mom_use_daily_range')?.checked ? 1 : 0,
      use_ema_filter: $('mom_use_ema_f')?.checked ? 1 : 0,
      use_rsi_max_filter: $('mom_use_rsi_max_f')?.checked ? 1 : 0,
      pattern_breakout: $('mom_pat_breakout')?.checked ? 1 : 0,
      pattern_pullback: $('mom_pat_pullback')?.checked ? 1 : 0,
      chase_lookback_min: v('mom_chase_lookback') || '10',
      pullback_lookback_min: v('mom_pullback_lookback') || '15',
      pullback_min_pct: v('mom_pullback_min') || '0.3',
      pullback_max_pct: v('mom_pullback_max') || '3.0',
      setup_vol_max_mult: v('mom_setup_vol') || '0.8',
      setup_bear_bars_min: v('mom_setup_bear') || '1',
      ema_fast_period: v('mom_ema_fast') || '9',
      ema_slow_period: v('mom_ema_slow') || '21',
      mom_rsi_min:    v('mom_rsi_min'),
      mom_rsi_max:    v('mom_rsi_max'),
      mom_vol_mult:   v('mom_vol_mult'),
      mom_vol_win:    v('mom_vol_win'),
      mom_time_end:   v('mom_time_end_buy'),
      mom_max_from_open_pct: v('mom_max_from_open') || '999',
      mom_min_from_open_pct: v('mom_min_from_open') || '-999',
      universe: $('mom_use_univ_history')?.checked ? 'history' : 'sim',
      daily_trail_tiers: (v('mom_daily_trail_tiers') || '').trim(),
      daily_trail_drop_pct: v('mom_daily_trail_drop') || '0',
      daily_trail_arm_krw: v('mom_daily_trail_arm') || '0',
      daily_profit_mode: ($('mom_daily_profit_mode') && $('mom_daily_profit_mode').value) || 'trailing',
      eod_enabled: $('mom_eod_enabled')?.checked ? 1 : 0,
      eod_hm: ($('mom_eod_hm') && $('mom_eod_hm').value.trim()) || '15:25',
    };
    const qs = new URLSearchParams(params).toString();
    fetch('/api/backtest/momentum?' + qs)
      .then(async r => {
        const d = await r.json().catch(() => ({}));
        if (!r.ok) throw new Error(d.error || ('HTTP ' + r.status));
        return d;
      })
      .then(d => { showSpinner(false); renderMomentumBacktest(d); })
      .catch(err => {
        showSpinner(false);
        alert('백테스트 오류: ' + err + '\n\n※ 저장 후보 이력 체크 시 1~3분 걸릴 수 있습니다. 기간을 줄이거나 체크 해제 후 재시도하세요.');
      });
  } catch (err) {
    showSpinner(false);
    alert('백테스트 실행 오류: ' + err);
  }
}

function saveMomentumConfig() {
  const body = {
    sl_pct:         parseFloat($('mom_sl').value),
    tp_pct:         parseFloat($('mom_tp').value),
    tp_max_pct:     parseFloat($('mom_tp_max').value),
    slot_money:     parseInt($('mom_slot_money').value, 10) || 0,
    slots:          parseInt($('mom_slots').value, 10) || 3,
    total_budget_krw: parseInt($('mom_total_budget')?.value || '0', 10),
    cooldown_min:   parseFloat($('mom_cooldown').value),
    shoulder_min_high: parseFloat($('mom_smin').value),
    shoulder_cut_pct:  parseFloat($('mom_scut').value),
    trail_pct:      parseFloat($('mom_trail')?.value || '0'),
    trail_arm_pct:  parseFloat($('mom_trail_arm')?.value || '0'),
    max_hold_bars:  parseInt($('mom_max_hold')?.value || '0', 10),
    ratchet_tiers:  ($('mom_ratchet')?.value || '').trim(),
    time_start:     parseInt($('mom_time_start').value, 10),
    max_daily:      parseInt($('mom_max_daily').value, 10),
    high_chase_thr: parseFloat($('mom_high_chase').value),
    max_daily_chg:  parseFloat($('mom_max_daily_chg').value),
    min_price:      parseFloat($('mom_min_price').value),
    max_loss_krw:   parseInt($('mom_max_loss_krw').value, 10),
    min_margin:     parseFloat($('mom_min_margin').value),
    use_defense_filters: !!($('mom_use_defense')?.checked),
    use_high_chase_filter: !!($('mom_use_high_chase_f')?.checked),
    use_daily_range_filter: !!($('mom_use_daily_range')?.checked),
    use_ema_filter: !!($('mom_use_ema_f')?.checked),
    use_rsi_max_filter: !!($('mom_use_rsi_max_f')?.checked),
    pattern_breakout: !!($('mom_pat_breakout')?.checked),
    pattern_pullback: !!($('mom_pat_pullback')?.checked),
    chase_lookback_min: parseInt($('mom_chase_lookback')?.value || '10', 10),
    pullback_lookback_min: parseInt($('mom_pullback_lookback')?.value || '15', 10),
    pullback_min_pct: parseFloat($('mom_pullback_min')?.value || '0.3'),
    pullback_max_pct: parseFloat($('mom_pullback_max')?.value || '3.0'),
    setup_vol_max_mult: parseFloat($('mom_setup_vol')?.value || '0.8'),
    setup_bear_bars_min: parseInt($('mom_setup_bear')?.value || '1', 10),
    ema_fast_period: parseInt($('mom_ema_fast')?.value || '9', 10),
    ema_slow_period: parseInt($('mom_ema_slow')?.value || '21', 10),
    mom_rsi_min:    parseFloat($('mom_rsi_min').value),
    mom_rsi_max:    parseFloat($('mom_rsi_max').value),
    mom_vol_mult:   parseFloat($('mom_vol_mult').value),
    mom_vol_win:    parseInt($('mom_vol_win').value, 10),
    mom_time_end:   parseInt($('mom_time_end_buy').value, 10),
    mom_max_from_open_pct: parseFloat($('mom_max_from_open')?.value || '999'),
    mom_min_from_open_pct: parseFloat($('mom_min_from_open')?.value || '-999'),
    ob_filter: !!($('mom_ob_filter')?.checked),
    pg_filter: !!($('mom_pg_filter')?.checked),
    daily_trail_tiers: ($('mom_daily_trail_tiers') && $('mom_daily_trail_tiers').value.trim()) || '',
    daily_profit_mode: ($('mom_daily_profit_mode') && $('mom_daily_profit_mode').value) || 'trailing',
    eod_enabled: !!($('mom_eod_enabled')?.checked),
    eod_hm: ($('mom_eod_hm') && $('mom_eod_hm').value.trim()) || '15:25',
  };
  if (!confirm('💾 모멘텀 봇(MOMENTUM_*)에 현재 탭 값을 저장할까요?\n실행 중이면 다음 루프부터 반영됩니다.')) return;
  fetch('/api/backtest/momentum/save_config', {
    method: 'POST', headers: {'Content-Type': 'application/json'},
    body: JSON.stringify(body),
  }).then(r => r.json()).then(d => {
    if (d.error) { alert('❌ ' + d.error); return; }
    btAddPresetOption('mom_daily_trail_preset', $('mom_daily_trail_tiers') && $('mom_daily_trail_tiers').value);
    const byTbl = d.saved_by_table || {};
    const tblLines = Object.entries(byTbl).map(([t, ks]) => `  ${t}: ${ks.join(', ')}`).join('\n');
    alert(`✅ 저장 완료 (env_id: ${d.env_id})\n테이블:\n${tblLines || d.saved_keys?.join(', ')}`);
  }).catch(e => alert('오류: ' + e));
}

function renderMomentumBacktest(d) {
  if (!d || d.error) {
    alert('❌ ' + (d && d.error ? d.error : '백테스트 응답 없음'));
    return;
  }
  if (!d.summary) {
    alert('백테스트 응답 오류 (summary 없음)');
    return;
  }
  const s = d.summary || {};
  const trades = d.trades || [];
  const p = d.params || {};

  const momRa = $('mom_result_area');
  if (momRa) momRa.style.display = 'block';
  const momPb = $('mom_params_bar');
  if (momPb) momPb.style.display = 'block';

  const setTxt = (id, txt) => { const el = $(id); if (el) el.textContent = txt; };
  const momUnivLabel = formatUniverseLabel(p);
  const effTp = p.effective_tp_pct != null ? p.effective_tp_pct : p.tp_pct;
  const periodHint = (p.start && p.end) ? ` | ${p.start}~${p.end}` : '';
  const patParts = [];
  if (p.pattern_breakout) patParts.push('돌파');
  if (p.pattern_pullback) patParts.push('눌림');
  const patLabel = patParts.length ? patParts.join('+') : '패턴OFF';
  const rsiLabel = p.use_rsi_max_filter
    ? `RSI ${p.mom_rsi_min}~${p.mom_rsi_max}`
    : `RSI≥${p.mom_rsi_min}`;
  const momSummaryLine =
    `<span class="badge ${(p.universe_source === 'history' || p.universe_source === 'history_strict') ? 'badge-info' : 'badge-secondary'}" title="target_candidates_history">${momUnivLabel}</span> ` +
    `${patLabel}(${p.chase_lookback_min || '—'}분) | ${rsiLabel} | 거래량×${p.mom_vol_mult} | ` +
    `손절-${p.sl_pct}% | 어깨(${p.shoulder_min_high}%/${p.shoulder_cut_pct}%)` +
    `${p.ratchet_tiers ? '·래칫' + p.ratchet_tiers : ''} | ` +
    `익절상한+${effTp}%${periodHint} | ` +
    `EMA ${p.use_ema_filter ? `ON(${p.ema_fast_period}/${p.ema_slow_period})` : 'OFF'} | ` +
    `1회 ${Number(p.slot_money||0).toLocaleString()}원 · 동시${p.max_stocks||'?'}종 · 한도 ${Number(p.total_budget_krw||0).toLocaleString()}원 | ` +
    `쿨다운${p.cooldown_min}분 | 일${p.max_daily || '—'}회 | 종목수 ${p.codes_analyzed || '—'}개`;

  if (momPb) momPb.innerHTML = momSummaryLine;
  const momCtx = $('mom_trade_context');
  if (momCtx) {
    momCtx.innerHTML = '📋 ' + momSummaryLine;
    momCtx.style.display = 'block';
  }

  setTxt('mom_total', (s.total_trades || 0) + '건');
  setTxt('mom_winrate', (s.win_rate || 0) + '%');
  colorPnl($('mom_winrate'), (s.win_rate || 0) - 50);

  setTxt('mom_pnl', fmtKrw(s.total_pnl));
  colorPnl($('mom_pnl'), s.total_pnl);

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

  setTxt('mom_mdd', '-' + fmtWon(s.max_drawdown) + '원');
  setTxt('mom_hold', (s.avg_hold_min || 0) + '분');

  const momSign = v => (v > 0 ? '+' : '');
  setTxt('mom_bot_pct', momSign(s.bot_pct || 0) + (s.bot_pct || 0) + '%');
  colorPnl($('mom_bot_pct'), s.bot_pct || 0);
  setTxt('mom_daily_avg_pct', momSign(s.daily_avg_pct || 0) + (s.daily_avg_pct || 0) + '%');
  colorPnl($('mom_daily_avg_pct'), s.daily_avg_pct || 0);

  if (s.budget_warning) {
    if (momPb) momPb.innerHTML += `<div class="mt-1" style="color:#e3b341;font-size:12px">💰 ${s.budget_warning}</div>`;
    if (momCtx) momCtx.innerHTML += `<div style="color:#e3b341;font-size:12px">💰 ${s.budget_warning}</div>`;
  }
  if ((s.total_trades || 0) === 0) {
    const zmsg = s.budget_warning
      ? `거래 0건 — ${s.budget_warning}`
      : '거래 0건 — 기간·유니버스·진입조건을 확인하세요. (총한도 < 동시×1회투자 이면 체결 불가)';
    const zhtml = `<div class="mt-2 p-2 rounded" style="color:#f85149;font-size:13px;border:1px solid #f85149;background:rgba(248,81,73,.08)">⚠️ ${zmsg}</div>`;
    if (momPb) momPb.innerHTML += zhtml;
    if (momCtx) momCtx.innerHTML += zhtml;
  }
  if (s.universe_warning) {
    if (momPb) momPb.innerHTML += `<div class="mt-1" style="color:#f85149;font-size:12px">⚠️ ${s.universe_warning}</div>`;
    if (momCtx) momCtx.innerHTML += `<div style="color:#f85149;font-size:12px">⚠️ ${s.universe_warning}</div>`;
  }
  const tickMeta = s.tick_backtest || {};
  const skipStats = s.skip_stats || {};
  if (tickMeta.tick_bar_coverage_pct != null || skipStats.tick_exit_count != null) {
    const cov = tickMeta.tick_bar_coverage_pct != null ? tickMeta.tick_bar_coverage_pct : '—';
    const rows = tickMeta.ws_tick_rows_loaded != null ? Number(tickMeta.ws_tick_rows_loaded).toLocaleString() : '—';
    const te = skipStats.tick_entry_count != null ? skipStats.tick_entry_count : '—';
    const tx = skipStats.tick_exit_count != null ? skipStats.tick_exit_count : '—';
    const oe = skipStats.ohlc_entry_count != null ? skipStats.ohlc_entry_count : '—';
    const ox = skipStats.ohlc_exit_count != null ? skipStats.ohlc_exit_count : '—';
    const tickLine =
      `📊 ws_ticks ${rows}건 · 분봉커버 ${cov}% | ` +
      `진입 틱${te}/시가${oe} · 청산 틱${tx}/OHLC${ox}`;
    if (momPb) momPb.innerHTML += `<div class="mt-1" style="color:#58a6ff;font-size:12px">${tickLine}</div>`;
    if (momCtx) momCtx.innerHTML += `<div style="color:#58a6ff;font-size:12px">${tickLine}</div>`;
  }
  if (skipStats.buy_queue_mode) {
    const qm = skipStats.buy_queue_mode === 'live_scan'
      ? `매수큐 실매형 ${skipStats.scan_sec || 10}초 · 스캔${skipStats.scan_events ?? '—'} · 매수${skipStats.scan_buys ?? '—'}`
      : '매수큐 분봉(레거시)';
    const um = skipStats.universe_mode === 'scan_at'
      ? `유니버스 스캔시각·디바운스${skipStats.universe_debounce_sec ?? 30}초`
      : (skipStats.universe_mode === 'minute_slot' ? '유니버스 분슬롯' : '');
    const line = um ? `🔄 ${qm} | ${um}` : `🔄 ${qm}`;
    if (momPb) momPb.innerHTML += `<div class="mt-1" style="color:#8b949e;font-size:12px">${line}</div>`;
    if (momCtx) momCtx.innerHTML += `<div style="color:#8b949e;font-size:12px">${line}</div>`;
  }
  const peakCum = Number(s.peak_cum_pnl || 0);
  const peakAt = fmtTradeTime(s.peak_cum_at || '');
  if (peakCum > 0 && peakAt) {
    const peakLine =
      `💡 장중 누적 최고 <b style="color:var(--green)">+${peakCum.toLocaleString()}원</b> (${peakAt})` +
      ` · 최종 <b>${fmtKrw(s.total_pnl)}</b>`;
    if (momPb) momPb.innerHTML += `<div class="mt-1" style="color:#8b949e;font-size:12px">${peakLine}</div>`;
    if (momCtx) momCtx.innerHTML += `<div style="color:#8b949e;font-size:12px">${peakLine}</div>`;
  }

  const winbar = $('mom_winbar_card');
  if ((s.total_trades || 0) > 0) {
    if (winbar) winbar.style.display = 'block';
    const wr = s.win_rate || 0;
    setTxt('mom_win_label', `🟢 승 ${s.win_trades || 0}건 (${wr}%)`);
    setTxt('mom_loss_label', `🔴 패 ${s.loss_trades || 0}건 (${(100 - wr).toFixed(1)}%)`);
    const rg = $('mom_ratio_g'); if (rg) rg.style.width = wr + '%';
    const rr = $('mom_ratio_r'); if (rr) rr.style.width = (100 - wr) + '%';
  } else if (winbar) {
    winbar.style.display = 'none';
  }

  try {
    if (d.equity && d.equity.length) {
      lineChart('mom_equity_chart',
        d.equity.map(e => e.date),
        d.equity.map(e => e.cum_pnl),
        '가상누적손익', '#3fb950');
    } else {
      destroyChart('mom_equity_chart');
    }

    const rKeys = Object.keys(d.reasons || {});
    if (rKeys.length) {
      doughnutChart('mom_reason_chart', rKeys, rKeys.map(k => d.reasons[k]));
    } else {
      destroyChart('mom_reason_chart');
    }

    if (d.daily && d.daily.length) {
      barChart('mom_daily_chart',
        d.daily.map(e => e.date.slice(5)),
        d.daily.map(e => e.pnl));
    } else {
      destroyChart('mom_daily_chart');
    }
  } catch (chartErr) {
    console.error('mom chart render', chartErr);
  }

  renderVirtualTrades('mom_tbody', trades || [], {
    showDebug: true,
    showCumulative: true,
    totalBudget: Number(p.total_budget_krw || 0),
  });
  if (momRa) momRa.scrollIntoView({ behavior: 'smooth', block: 'start' });
}

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

// 방어 ON/OFF 토글은 클릭 즉시 DB 저장 (실시간 반영)
async function saveScalpDefenseRealtime() {
  const enabled = !!($('bt_use_defense')?.checked);
  try {
    const r = await fetch('/api/backtest/scalping/save_config', {
      method: 'POST',
      headers: {'Content-Type': 'application/json'},
      body: JSON.stringify({ use_defense_filters: enabled }),
    });
    const d = await r.json();
    if (d.error) {
      alert('❌ 방어로직 저장 실패: ' + d.error);
      return;
    }
    // DB 저장만 즉시 반영 — 날짜·파라미터 조정 후 「백테스트 실행」으로 확인
  } catch (e) {
    alert('오류: ' + e);
  }
}

async function saveScalpMacdRealtime() {
  const enabled = !!($('bt_use_macd')?.checked);
  try {
    const r = await fetch('/api/backtest/scalping/save_config', {
      method: 'POST',
      headers: {'Content-Type': 'application/json'},
      body: JSON.stringify({ use_macd_cross: enabled }),
    });
    const d = await r.json();
    if (d.error) {
      alert('❌ MACD 모드 저장 실패: ' + d.error);
      return;
    }
    // MACD 백테는 reversal 보다 무거움 — 저장만 즉시 반영, 백테는 사용자가 실행
  } catch (e) {
    alert('오류: ' + e);
  }
}

function fillTailFormFromApi(t) {
  if (!t || typeof t !== 'object') return;
  const set = (id, val) => { if (val !== null && val !== undefined && $(id)) $(id).value = val; };
  set('tl_drop', t.drop);
  set('tl_rec',  t.rec);
  set('tl_tail', t.tail_ratio);
  set('tl_sl',   t.sl_pct);
  set('tl_tp',   t.tp_pct);
  set('tl_smin', t.smin);
  set('tl_scut', t.scut);
  if ($('tl_ratchet')) $('tl_ratchet').value = (t.ratchet_tiers != null ? String(t.ratchet_tiers) : '');
  if (t.trail_pct != null) set('tl_trail', t.trail_pct);
  if (t.trail_arm_pct != null) set('tl_trail_arm', t.trail_arm_pct);
  set('tl_cool', t.cool);
  set('tl_rsi',  t.rsi);
  set('tl_ts',   t.time_start);
  set('tl_te',   t.time_end);
  if (t.eod_enabled !== undefined && $('tl_eod_enabled')) {
    $('tl_eod_enabled').checked = !!t.eod_enabled;
  }
  if (t.eod_hm != null) set('tl_eod_hm', t.eod_hm);
  set('tl_maxd', t.max_daily);
  set('tl_rsi_period', t.rsi_period);
  set('tl_tail_pct', t.tail_pct_min);
  set('tl_max_rec_3m', t.max_rec_3m);
  set('tl_high_chase', t.high_chase);
  set('tl_min_price', t.min_price);
  set('tl_max_daily_change', t.max_daily_change);
  set('tl_ma20_above', t.ma20_max_above);
  set('tl_max_loss_krw', t.max_loss_krw);
  if (t.min_drop_pct_for_loss_cut != null) set('tl_min_drop_loss_cut', t.min_drop_pct_for_loss_cut);
  set('tl_stop_atr', t.stop_atr_mult);
  set('tl_target_atr', t.target_atr_mult);
  set('tl_atr_sl_min', t.atr_sl_min_pct);
  set('tl_atr_sl_max', t.atr_sl_max_pct);
  set('tl_atr_tp_min', t.atr_tp_min_pct);
  set('tl_atr_tp_max', t.atr_tp_max_pct);
  if (t.slot_money) set('tl_slot', t.slot_money);
  if (t.max_stocks != null) set('tl_max_stocks', t.max_stocks);
  if (t.total_budget_krw != null) set('tl_total_budget', t.total_budget_krw);
  if (t.entry_mode && $('tl_entry_mode')) $('tl_entry_mode').value = String(t.entry_mode).toLowerCase();
  set('tl_limit_atr_mult', t.limit_atr_mult);
  if (t.limit_anchor && $('tl_limit_anchor')) $('tl_limit_anchor').value = String(t.limit_anchor);
  set('tl_limit_valid_bars', t.limit_valid_bars);
  set('tl_limit_fill_slip', t.limit_fill_slip_pct);
  if ($('tl_skip_hts_dupes')) $('tl_skip_hts_dupes').checked = t.skip_hts_scan_dupes !== false;
  if ($('tl_use_rsi_filter')) $('tl_use_rsi_filter').checked = t.use_rsi_filter !== false;
  if ($('tl_use_ma20_filter')) $('tl_use_ma20_filter').checked = !!t.use_ma20_filter;
  if ($('tl_use_daily_range')) $('tl_use_daily_range').checked = t.use_daily_range_filter !== false;
  if ($('tl_use_high_chase_f')) $('tl_use_high_chase_f').checked = t.use_high_chase_filter !== false;
  if ($('tl_use_intraday_drop')) $('tl_use_intraday_drop').checked = !!t.use_intraday_drop;
  if (t.bar_chg_min_pct != null) set('tl_bar_chg_min', t.bar_chg_min_pct);
  if (t.bar_chg_max_pct != null) set('tl_bar_chg_max', t.bar_chg_max_pct);
  if (t.tail_vol_mult != null) set('tl_tail_vol_mult', t.tail_vol_mult);
  if (t.tail_vol_win != null) set('tl_tail_vol_win', t.tail_vol_win);
  if ($('tl_use_tick_db')) $('tl_use_tick_db').checked = t.backtest_use_tick_db === true;
  if ($('tl_tick_fallback_ohlc')) $('tl_tick_fallback_ohlc').checked = t.backtest_tick_fallback_ohlc !== false;
  if ($('tl_pat_hammer')) $('tl_pat_hammer').checked = t.pattern_hammer !== false;
  if ($('tl_pat_pin')) $('tl_pat_pin').checked = !!t.pattern_pin;
  if ($('tl_pat_engulfing')) $('tl_pat_engulfing').checked = !!t.pattern_engulfing;
  if ($('tl_pat_piercing')) $('tl_pat_piercing').checked = !!t.pattern_piercing;
  if ($('tl_pat_harami')) $('tl_pat_harami').checked = !!t.pattern_harami;
  if ($('tl_pat_doji')) $('tl_pat_doji').checked = !!t.pattern_doji;
  if ($('tl_pat_morning_star')) $('tl_pat_morning_star').checked = !!t.pattern_morning_star;
  if (t.ob_filter_enabled !== undefined && $('tl_ob_filter')) {
    $('tl_ob_filter').checked = !!t.ob_filter_enabled;
  }
  if (t.max_spread_pct != null && $('tl_max_spread_pct')) {
    $('tl_max_spread_pct').value = t.max_spread_pct;
  }
  if (t.pg_filter_enabled !== undefined && $('tl_pg_filter')) {
    $('tl_pg_filter').checked = !!t.pg_filter_enabled;
  }
  // 당일 누적손익 다단 트레일(SHORT 일일익절) 현재값·방식 복원
  if ($('tl_daily_trail_tiers')) {
    $('tl_daily_trail_tiers').value = (t.daily_trail_tiers != null ? String(t.daily_trail_tiers) : '');
  }
  if (t.daily_profit_mode && $('tl_daily_profit_mode')) {
    $('tl_daily_profit_mode').value = t.daily_profit_mode;
  }
  // DB(env)에 저장된 사용자 프리셋 목록(세미콜론) → 드롭다운 ★옵션 복원
  btFillPresetOptions('tl_ratchet_preset', t.ratchet_presets);
  btFillPresetOptions('tl_daily_trail_preset', t.daily_trail_presets);
  tlSyncRatchetShoulderColors();
}

// 파람서치 그리드 프리셋 드롭다운 → 텍스트 입력칸 채우기.
// '__custom__'(직접입력)은 무시, 'off'는 빈칸(=단일 어깨/미사용)으로 채운다.
function btApplyPreset(targetId, val, syncColors) {
  if (val === '__custom__') return;
  const el = document.getElementById(targetId);
  if (!el) return;
  el.value = (val === 'off') ? '' : val;
  if (syncColors && typeof tlSyncRatchetShoulderColors === 'function') {
    tlSyncRatchetShoulderColors();
  }
}

// ── 커스텀 프리셋(직접입력값) 드롭다운 ─ DB(env) 영구 저장본 기반 ──────────
// 봇에 설정저장하면 서버가 BT_RATCHET_PRESETS / BT_DAILY_TRAIL_PRESETS(세미콜론 목록)에
// 값을 누적 저장하고, 페이지 로드 시 /api/env/params 응답으로 내려준다.
// 여기서는 그 목록을 드롭다운에 ★옵션으로 채우는 일만 한다.

// 드롭다운에 값 하나를 ★옵션으로 추가(중복·빈값 제외)
function btAddPresetOption(selId, val) {
  const sel = document.getElementById(selId);
  if (!sel) return;
  const v = String(val || '').trim();
  if (!v) return;
  for (const opt of sel.options) { if (opt.value === v) return; }
  const o = document.createElement('option');
  o.value = v; o.textContent = '★ ' + v;
  sel.appendChild(o);
}

// 세미콜론 구분 목록 문자열을 드롭다운에 일괄 채움
function btFillPresetOptions(selId, listStr) {
  if (!listStr) return;
  for (const v of String(listStr).split(';')) {
    btAddPresetOption(selId, v);
  }
}

// 돌파 손절모드 색상 — sl_mode=fixed vs atr 배타. atr ON 이면 고정 손절(%) 빨강(무시), atr OFF 이면 ATR 필드 빨강.
function boSyncSlModeColors() {
  const modeEl = $('bo_sl_mode');
  if (!modeEl) return;
  const slEl = $('bo_sl');
  const atrIds = ['bo_atr_period', 'bo_atr_sl_mult', 'bo_atr_sl_min', 'bo_atr_sl_max'];
  const atrOn = String(modeEl.value || 'fixed').toLowerCase() === 'atr';
  const RED = '#f85149';
  const paint = (el, inactive) => {
    if (!el) return;
    if (inactive) {
      el.style.setProperty('border-color', RED, 'important');
      el.style.setProperty('background-color', 'rgba(248,81,73,0.12)', 'important');
      el.style.setProperty('box-shadow', '0 0 0 1px ' + RED, 'important');
    } else {
      el.style.removeProperty('border-color');
      el.style.removeProperty('background-color');
      el.style.removeProperty('box-shadow');
    }
  };
  paint(slEl, atrOn);
  atrIds.forEach(id => paint($(id), !atrOn));
}

// 래칫/어깨컷 입력칸 색상 토글 — 청산 엔진은 둘이 배타(if/else)다.
// 래칫 값이 있으면 어깨컷(어깨발동·어깨폭)은 실행 안 됨 → 어깨컷 칸을 빨강(무시됨 표시).
// 래칫이 비면 래칫 칸을 빨강(OFF), 어깨컷은 원래색(이때 어깨컷이 1순위 트레일).
function tlSyncRatchetShoulderColors() {
  const r = $('tl_ratchet');
  if (!r) return;
  const smin = $('tl_smin');
  const scut = $('tl_scut');
  const ratchetOn = !!(r.value && String(r.value).trim());
  const RED = '#f85149';
  // CSS .form-control 가 border 를 !important 로 강제하므로 인라인도 important 로 줘야 이김
  const paint = (el, on) => {
    if (!el) return;
    if (on) {
      el.style.setProperty('border-color', RED, 'important');
      el.style.setProperty('background-color', 'rgba(248,81,73,0.12)', 'important');
      el.style.setProperty('box-shadow', '0 0 0 1px ' + RED, 'important');
    } else {
      el.style.removeProperty('border-color');
      el.style.removeProperty('background-color');
      el.style.removeProperty('box-shadow');
    }
    el.title = on
      ? (el === r
          ? '래칫 OFF — 현재 어깨컷이 1순위 트레일로 동작'
          : '래칫 ON — 이 어깨컷 값은 무시됨(래칫이 1순위 트레일)')
      : (el.getAttribute('data-orig-title') || el.title);
  };
  // 래칫 ON → 어깨컷 빨강 / 래칫 OFF → 래칫 빨강
  paint(smin, ratchetOn);
  paint(scut, ratchetOn);
  paint(r, !ratchetOn);
}

function fillMomentumFormFromApi(m) {
  if (!m || typeof m !== 'object') return;
  const set = (id, val) => { if (val !== null && val !== undefined && $(id)) $(id).value = val; };
  set('mom_rsi_min', m.mom_rsi_min);
  set('mom_rsi_max', m.mom_rsi_max);
  set('mom_vol_mult', m.mom_vol_mult);
  set('mom_vol_win', m.mom_vol_win);
  set('mom_time_end_buy', m.mom_time_end_hm);
  set('mom_time_start', m.mom_time_start_hm);
  if (m.eod_enabled !== undefined && $('mom_eod_enabled')) {
    $('mom_eod_enabled').checked = !!m.eod_enabled;
  }
  if (m.eod_hm != null) set('mom_eod_hm', m.eod_hm);
  set('mom_sl', m.sl_pct);
  set('mom_tp', m.tp_pct);
  set('mom_tp_max', m.tp_max_pct);
  set('mom_smin', m.shoulder_min_high);
  set('mom_scut', m.shoulder_cut_pct);
  if (m.trail_pct != null) set('mom_trail', m.trail_pct);
  if (m.trail_arm_pct != null) set('mom_trail_arm', m.trail_arm_pct);
  if (m.max_hold_bars != null) set('mom_max_hold', m.max_hold_bars);
  if ($('mom_ratchet')) $('mom_ratchet').value = (m.ratchet_tiers != null ? String(m.ratchet_tiers) : '');
  set('mom_cooldown', m.cooldown_min);
  set('mom_max_daily', m.max_daily);
  set('mom_slot_money', m.slot_money);
  set('mom_slots', m.mom_slots);
  if (m.total_budget_krw != null) set('mom_total_budget', m.total_budget_krw);
  const momPw = $('mom_portfolio_warn');
  if (momPw) {
    if (m.portfolio_ui_warning) {
      momPw.textContent = '⚠️ ' + m.portfolio_ui_warning;
      momPw.style.display = 'block';
    } else {
      momPw.style.display = 'none';
      momPw.textContent = '';
    }
  }
  set('mom_high_chase', m.high_chase_thr);
  set('mom_max_daily_chg', m.max_daily_chg);
  set('mom_min_price', m.min_price);
  set('mom_max_loss_krw', m.max_loss_krw);
  set('mom_min_margin', m.min_margin);
  set('mom_max_from_open', m.mom_max_from_open_pct);
  set('mom_min_from_open', m.mom_min_from_open_pct);
  if ($('mom_pat_breakout') && m.pattern_breakout !== undefined) {
    $('mom_pat_breakout').checked = !!m.pattern_breakout;
  }
  if ($('mom_pat_pullback') && m.pattern_pullback !== undefined) {
    $('mom_pat_pullback').checked = !!m.pattern_pullback;
  }
  set('mom_chase_lookback', m.chase_lookback_min);
  set('mom_pullback_lookback', m.pullback_lookback_min);
  set('mom_pullback_min', m.pullback_min_pct);
  set('mom_pullback_max', m.pullback_max_pct);
  set('mom_setup_vol', m.setup_vol_max_mult);
  set('mom_setup_bear', m.setup_bear_bars_min);
  if (m.use_defense_filters !== undefined && $('mom_use_defense')) {
    $('mom_use_defense').checked = !!m.use_defense_filters;
  }
  if ($('mom_use_high_chase_f') && m.use_high_chase_filter !== undefined) {
    $('mom_use_high_chase_f').checked = !!m.use_high_chase_filter;
  }
  if ($('mom_use_daily_range') && m.use_daily_range_filter !== undefined) {
    $('mom_use_daily_range').checked = !!m.use_daily_range_filter;
  }
  if ($('mom_use_ema_f') && m.use_ema_filter !== undefined) {
    $('mom_use_ema_f').checked = !!m.use_ema_filter;
  }
  if ($('mom_use_rsi_max_f') && m.use_rsi_max_filter !== undefined) {
    $('mom_use_rsi_max_f').checked = !!m.use_rsi_max_filter;
  }
  if (m.ema_fast_period != null) set('mom_ema_fast', m.ema_fast_period);
  if (m.ema_slow_period != null) set('mom_ema_slow', m.ema_slow_period);
  // 당일 누적손익 다단 트레일(꼬리와 동일) 현재값·방식·프리셋 복원
  if ($('mom_daily_trail_tiers')) {
    $('mom_daily_trail_tiers').value = (m.daily_trail_tiers != null ? String(m.daily_trail_tiers) : '');
  }
  if (m.daily_profit_mode && $('mom_daily_profit_mode')) {
    $('mom_daily_profit_mode').value = m.daily_profit_mode;
  }
  btFillPresetOptions('mom_daily_trail_preset', m.daily_trail_presets);
  // 체크값 = 모멘텀 실효값(전용 우선·없으면 글로벌 상속). 밑에 글로벌/실매 출처를 표시.
  const filterNote = (en, glob, explicit) => {
    const onoff = v => (v ? 'ON' : 'OFF');
    const src = explicit
      ? '모멘텀 전용 설정값'
      : `글로벌 상속(전용 미설정)`;
    // 실매도 동일 규칙(orderbook_filter.orderbook_filter_enabled) → 실매 적용값 = 실효값
    return `글로벌 ${onoff(glob)} · 실매 ${onoff(en)} · ${src}`;
  };
  if (m.ob_filter_enabled !== undefined && $('mom_ob_filter')) {
    $('mom_ob_filter').checked = !!m.ob_filter_enabled;
    if ($('mom_ob_filter_note')) {
      $('mom_ob_filter_note').textContent = filterNote(
        !!m.ob_filter_enabled, !!m.ob_global_enabled, !!m.ob_strategy_explicit,
      );
    }
  }
  if (m.pg_filter_enabled !== undefined && $('mom_pg_filter')) {
    $('mom_pg_filter').checked = !!m.pg_filter_enabled;
    if ($('mom_pg_filter_note')) {
      $('mom_pg_filter_note').textContent = filterNote(
        !!m.pg_filter_enabled, !!m.pg_global_enabled, !!m.pg_strategy_explicit,
      );
    }
  }
  if (m.max_spread_pct != null) set('mom_max_spread_pct', m.max_spread_pct);
}

function loadMomentumSearchResults() {
  showSpinner(true);
  fetch('/api/backtest/momentum/search_results?top=30')
    .then(r => r.json())
    .then(d => {
      showSpinner(false);
      if (d.error) { alert('❌ ' + d.error); return; }
      momRenderParamSearch(d.top || [], d.meta || {});
    })
    .catch(err => { showSpinner(false); alert('오류: ' + err); });
}

function momApplySearchResult(rank, saveDb) {
  showSpinner(true);
  fetch('/api/backtest/momentum/apply_search', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({ rank, save_db: !!saveDb }),
  })
    .then(r => r.json())
    .then(d => {
      showSpinner(false);
      if (d.error) { alert('❌ ' + d.error); return; }
      if (d.ui) fillMomentumFormFromApi(d.ui);
      const m = d.metrics || {};
      const msg = saveDb
        ? `✅ ${rank}위 → 폼 반영 + DB 저장 (env_id: ${d.env_id || '-'})\n손익 ${fmtKrw(m.total_pnl)} · 승률 ${m.win_rate}% · PF ${m.pf} · ${m.total_trades}건`
        : `✅ ${rank}위 → 폼만 반영 (DB 미저장)\n손익 ${fmtKrw(m.total_pnl)} · 승률 ${m.win_rate}% · PF ${m.pf}`;
      alert(msg);
    })
    .catch(err => { showSpinner(false); alert('오류: ' + err); });
}

function momRenderParamSearch(top, meta) {
  window._mom_last_top = top || [];
  const area = $('mom_search_result');
  if (area) area.style.display = '';
  const labelMap = {
    mom_rsi_min: 'RSI min',
    mom_rsi_max: 'RSI max',
    mom_vol_mult: '거래량×',
    mom_vol_win: '관찰봉',
    sl_pct: '손절%',
    tp_pct: '익절%',
    tp_max_pct: '익절상한%',
    shoulder_min_high: '어깨발동',
    shoulder_cut_pct: '어깨컷',
    trail_trigger: '트레일무장%',
    trail_stop: '트레일%',
    mom_max_from_open_pct: '시가+과열',
  };
  const pctKeys = new Set([
    'sl_pct', 'tp_pct', 'tp_max_pct', 'shoulder_min_high', 'shoulder_cut_pct',
    'trail_trigger', 'trail_stop', 'trail_pct', 'trail_arm_pct',
  ]);
  const fixedLabel = (k) => labelMap[k] || k;
  const keys = top && top.length ? Object.keys(top[0].params || {}) : [];
  const thead = $('mom_search_thead');
  if (thead) {
    thead.innerHTML = '<tr><th>순위</th>' +
      keys.map(k => `<th>${fixedLabel(k)}</th>`).join('') +
      '<th>손익(원)</th><th>승률</th><th>거래</th><th>PF</th><th>보유(분)</th><th>폼반영</th><th>DB저장</th></tr>';
  }
  const tbody = $('mom_search_tbody');
  if (!tbody) return;
  tbody.innerHTML = '';
  (top || []).forEach((r) => {
    const p = r.params || {};
    const pnlCls = r.total_pnl > 0 ? 'text-pnl-pos' : (r.total_pnl < 0 ? 'text-pnl-neg' : '');
    const paramCells = keys.map(k => {
      const v = p[k];
      if (v === undefined || v === null) return '<td>-</td>';
      if (pctKeys.has(k) && typeof v === 'number') return `<td>${Number(v).toFixed(2)}%</td>`;
      return `<td>${v}</td>`;
    }).join('');
    const rank = r.rank;
    tbody.insertAdjacentHTML('beforeend', `
      <tr>
        <td><b>${rank}</b></td>
        ${paramCells}
        <td class="${pnlCls}">${fmtWon(r.total_pnl)}</td>
        <td>${r.win_rate}%</td><td>${r.total_trades}</td>
        <td>${r.pf}</td><td>${r.avg_hold_min != null ? r.avg_hold_min : '-'}</td>
        <td><button class="btn btn-xs btn-outline-primary" style="font-size:11px;padding:1px 6px"
            onclick="momApplySearchResult(${rank}, false)">폼</button></td>
        <td><button class="btn btn-xs btn-outline-success" style="font-size:11px;padding:1px 6px"
            onclick="momApplySearchResult(${rank}, true)">저장</button></td>
      </tr>`);
  });
  const hint = $('mom_search_hint');
  if (hint) {
    hint.style.display = '';
    const gh = meta.grid_axis_hints && typeof meta.grid_axis_hints === 'object'
      ? '<br>' + Object.entries(meta.grid_axis_hints).map(([k, txt]) =>
          `<div style="margin:3px 0 0 8px;border-left:2px solid #30363d;padding-left:8px"><b>${fixedLabel(k)}</b> — ${txt}</div>`
        ).join('')
      : '';
    hint.innerHTML =
      `<span style="color:var(--accent)">파일:</span> <code>${meta.path || '?'}</code> · `
      + `<b>${meta.start || '?'}</b> ~ <b>${meta.end || '?'}</b> · `
      + `모드 ${meta.mode || '-'} · 조합 ${meta.tested_combos || '-'} / ${meta.cartesian_product || '-'}<br>`
      + `<b>폼반영</b>: 입력란만 갱신 · <b>DB저장</b>: config_momentum MOMENTUM_* INSERT 후 폼 동기화`
      + ` (총손익≤0 은 거부).`
      + gh;
  }
  if (area) area.scrollIntoView({ behavior: 'smooth' });
}

function loadTailSearchResults() {
  showSpinner(true);
  fetch('/api/backtest/tail/search_results?top=30')
    .then(r => r.json())
    .then(d => {
      showSpinner(false);
      if (d.error) { alert('❌ ' + d.error); return; }
      tlRenderParamSearch(d.top || [], d.meta || {});
    })
    .catch(err => { showSpinner(false); alert('오류: ' + err); });
}

function tlApplySearchResult(rank, saveDb) {
  showSpinner(true);
  fetch('/api/backtest/tail/apply_search', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({ rank, save_db: !!saveDb }),
  })
    .then(r => r.json())
    .then(d => {
      showSpinner(false);
      if (d.error) { alert('❌ ' + d.error); return; }
      if (d.ui) fillTailFormFromApi(d.ui);
      const m = d.metrics || {};
      const msg = saveDb
        ? `✅ ${rank}위 → 폼 반영 + DB 저장 (env_id: ${d.env_id || '-'})\n손익 ${fmtKrw(m.total_pnl)} · 승률 ${m.win_rate}% · PF ${m.pf} · ${m.total_trades}건`
        : `✅ ${rank}위 → 폼만 반영 (DB 미저장)\n손익 ${fmtKrw(m.total_pnl)} · 승률 ${m.win_rate}% · PF ${m.pf}`;
      alert(msg);
    })
    .catch(err => { showSpinner(false); alert('오류: ' + err); });
}

function tlRenderParamSearch(top, meta) {
  window._tl_last_top = top || [];
  $('tl_search_result').style.display = '';
  const labelMap = {
    max_daily_change: '급등컷%',
    min_drop_rate: '낙폭',
    min_recovery_ratio: '회복',
    tail_ratio_min: '꼬리/몸통',
    max_rec_3m: '3분회복',
    shoulder_min_high: '어깨발동',
    shoulder_cut_pct: '어깨컷',
    stop_atr_mult: 'ATR손절',
    target_atr_mult: 'ATR익절',
    limit_atr_mult: '지정가ATR',
    entry_mode: '진입모드',
    trail_pct: '보조트레일%',
    trail_arm_pct: '트레일무장%',
  };
  const fracKeys = new Set(['min_drop_rate', 'min_recovery_ratio', 'shoulder_min_high', 'shoulder_cut_pct']);
  const ratioKeys = new Set(['max_rec_3m']);
  const fixedLabel = (k) => labelMap[k] || k;
  const keys = top && top.length ? Object.keys(top[0].params || {}) : [];
  const thead = $('tl_search_thead');
  thead.innerHTML = '<tr><th>순위</th>' +
    keys.map(k => `<th>${fixedLabel(k)}</th>`).join('') +
    '<th>손익(원)</th><th>승률</th><th>거래</th><th>PF</th><th>보유(분)</th><th>폼반영</th><th>DB저장</th></tr>';
  const tbody = $('tl_search_tbody');
  tbody.innerHTML = '';
  (top || []).forEach((r) => {
    const p = r.params || {};
    const pnlCls = r.total_pnl > 0 ? 'text-pnl-pos' : (r.total_pnl < 0 ? 'text-pnl-neg' : '');
    const paramCells = keys.map(k => {
      const v = p[k];
      if (v === undefined || v === null) return '<td>-</td>';
      if (fracKeys.has(k)) return `<td>${(Number(v) * 100).toFixed(2)}%</td>`;
      if (ratioKeys.has(k)) return `<td>${Number(v) <= 1 ? (Number(v) * 100).toFixed(0) : v}%</td>`;
      return `<td>${v}</td>`;
    }).join('');
    const rank = r.rank;
    tbody.insertAdjacentHTML('beforeend', `
      <tr>
        <td><b>${rank}</b></td>
        ${paramCells}
        <td class="${pnlCls}">${fmtWon(r.total_pnl)}</td>
        <td>${r.win_rate}%</td><td>${r.total_trades}</td>
        <td>${r.pf}</td><td>${r.avg_hold_min != null ? r.avg_hold_min : '-'}</td>
        <td><button class="btn btn-xs btn-outline-primary" style="font-size:11px;padding:1px 6px"
            onclick="tlApplySearchResult(${rank}, false)">폼</button></td>
        <td><button class="btn btn-xs btn-outline-success" style="font-size:11px;padding:1px 6px"
            onclick="tlApplySearchResult(${rank}, true)">저장</button></td>
      </tr>`);
  });
  const hint = $('tl_search_hint');
  if (hint) {
    hint.style.display = '';
    const gh = meta.grid_axis_hints && typeof meta.grid_axis_hints === 'object'
      ? '<br>' + Object.entries(meta.grid_axis_hints).map(([k, txt]) =>
          `<div style="margin:3px 0 0 8px;border-left:2px solid #30363d;padding-left:8px"><b>${fixedLabel(k)}</b> — ${txt}</div>`
        ).join('')
      : '';
    hint.innerHTML =
      `<span style="color:var(--accent)">파일:</span> <code>${meta.path || '?'}</code> · `
      + `<b>${meta.start || '?'}</b> ~ <b>${meta.end || '?'}</b> · TF ${meta.timeframe || 3}분 · `
      + `조합 ${meta.tested_combos || '-'} / ${meta.cartesian_product || '-'}<br>`
      + `<b>폼반영</b>: 입력란만 갱신 · <b>DB저장</b>: config_short TAIL_* INSERT 후 폼 동기화`
      + ` (총손익≤0 은 거부). 승률·PF가 높은 조합은 순위와 다를 수 있음 — 표 <b>순위</b> 열 기준.`
      + gh;
  }
  $('tl_search_result').scrollIntoView({behavior: 'smooth'});
}

function saveTailConfig() {
  const body = {
    min_drop_rate:      parseFloat($('tl_drop').value),
    min_recovery_ratio: parseFloat($('tl_rec').value),
    tail_ratio_min:     parseFloat($('tl_tail').value),
    tail_pct_min:       parseFloat($('tl_tail_pct').value),
    max_rec_3m:         parseFloat($('tl_max_rec_3m').value),
    sl_pct:             parseFloat($('tl_sl').value),
    tp_pct:             parseFloat($('tl_tp').value),
    shoulder_cut_pct:   parseFloat($('tl_scut').value),
    shoulder_min_high:  parseFloat($('tl_smin').value),
    high_chase_thr:     parseFloat($('tl_high_chase').value),
    cooldown_min:       parseFloat($('tl_cool').value),
    rsi_threshold:      parseFloat($('tl_rsi').value),
    rsi_period:         parseInt($('tl_rsi_period').value, 10),
    time_start:         parseInt($('tl_ts').value, 10),
    time_end:           parseInt($('tl_te').value, 10),
    max_daily:          parseInt($('tl_maxd').value, 10),
    min_price:          parseFloat($('tl_min_price').value),
    max_daily_change:   parseFloat($('tl_max_daily_change').value),
    ma20_max_above:     parseFloat($('tl_ma20_above').value),
    max_loss_krw:       parseInt($('tl_max_loss_krw').value, 10),
    min_drop_pct_for_loss_cut: parseFloat($('tl_min_drop_loss_cut').value) || 1.5,  // % 단위로 전송 (1.5)
    stop_atr_mult:      parseFloat($('tl_stop_atr').value),
    target_atr_mult:    parseFloat($('tl_target_atr').value),
    atr_sl_min_pct:     parseFloat($('tl_atr_sl_min').value),
    atr_sl_max_pct:     parseFloat($('tl_atr_sl_max').value),
    atr_tp_min_pct:     parseFloat($('tl_atr_tp_min').value),
    atr_tp_max_pct:     parseFloat($('tl_atr_tp_max').value),
    slot_money:         parseInt($('tl_slot')?.value || '3000000', 10) || 3000000,
    max_stocks:         parseInt($('tl_max_stocks')?.value || '3', 10) || 3,
    total_budget_krw:   parseInt($('tl_total_budget')?.value || '0', 10) || 0,
    entry_mode:         ($('tl_entry_mode') && $('tl_entry_mode').value) || 'limit_atr',
    limit_atr_mult:     parseFloat($('tl_limit_atr_mult')?.value || '1.5'),
    limit_anchor:       ($('tl_limit_anchor') && $('tl_limit_anchor').value) || 'signal_low',
    limit_valid_bars:   parseInt($('tl_limit_valid_bars')?.value || '1', 10) || 1,
    limit_fill_slip_pct: parseFloat($('tl_limit_fill_slip')?.value || '0'),
    skip_hts_scan_dupes: $('tl_skip_hts_dupes')?.checked !== false,
    use_intraday_drop: !!$('tl_use_intraday_drop')?.checked,
    use_ma20_filter: !!$('tl_use_ma20_filter')?.checked,
    use_rsi_filter: $('tl_use_rsi_filter')?.checked !== false,
    use_daily_range_filter: $('tl_use_daily_range')?.checked !== false,
    use_high_chase_filter: $('tl_use_high_chase_f')?.checked !== false,
    bar_chg_min_pct: parseFloat($('tl_bar_chg_min')?.value || '-10'),
    bar_chg_max_pct: parseFloat($('tl_bar_chg_max')?.value || '-1.5'),
    tail_vol_mult: parseFloat($('tl_tail_vol_mult')?.value || '0'),
    tail_vol_win: parseInt($('tl_tail_vol_win')?.value || '5', 10) || 5,
    ratchet_tiers: ($('tl_ratchet') && $('tl_ratchet').value.trim()) || '',
    daily_trail_tiers: ($('tl_daily_trail_tiers') && $('tl_daily_trail_tiers').value.trim()) || '',
    daily_profit_mode: ($('tl_daily_profit_mode') && $('tl_daily_profit_mode').value) || 'trailing',
    max_hold_bars: 0,
    trail_pct: parseFloat($('tl_trail')?.value || '0'),
    trail_arm_pct: parseFloat($('tl_trail_arm')?.value || '0'),
    backtest_use_tick_db: $('tl_use_tick_db')?.checked !== false,
    backtest_tick_fallback_ohlc: $('tl_tick_fallback_ohlc')?.checked !== false,
    pattern_hammer: $('tl_pat_hammer')?.checked !== false,
    pattern_pin: !!$('tl_pat_pin')?.checked,
    pattern_engulfing: !!$('tl_pat_engulfing')?.checked,
    pattern_piercing: !!$('tl_pat_piercing')?.checked,
    pattern_harami: !!$('tl_pat_harami')?.checked,
    pattern_doji: !!$('tl_pat_doji')?.checked,
    pattern_morning_star: !!$('tl_pat_morning_star')?.checked,
    ob_filter: !!($('tl_ob_filter')?.checked),
    pg_filter: !!($('tl_pg_filter')?.checked),
    eod_enabled: !!($('tl_eod_enabled')?.checked),
    eod_hm: ($('tl_eod_hm') && $('tl_eod_hm').value.trim()) || '15:25',
  };
  if (!confirm(`💾 꼬리잡기 봇(config_short TAIL_*)에 저장합니까?\n\n` +
    `1회투자: ${body.slot_money}원 | 동시보유: ${body.max_stocks}종 | 총운용한도: ${body.total_budget_krw || body.max_stocks * body.slot_money}원 | ` +
    `낙폭: ${body.min_drop_rate}% | 회복: ${body.min_recovery_ratio}% | 꼬리/몸통: ${body.tail_ratio_min} | 꼬리최소: ${body.tail_pct_min}%\n` +
    `손절: ${body.sl_pct}% | 익절: ${body.tp_pct}% | 어깨컷: ${body.shoulder_cut_pct}% | 3분최대회복: ${body.max_rec_3m}% | 고점추격방지: ${body.high_chase_thr}%\n` +
    `최소가격: ${body.min_price}원 | 최대급등: ${body.max_daily_change}% | MA20이격: ${body.ma20_max_above}% | 최대손실: ${body.max_loss_krw}원\n` +
    `ATR손절: ${body.stop_atr_mult}배 | ATR익절: ${body.target_atr_mult}배 | ` +
    `캡 SL ${body.atr_sl_min_pct}~${body.atr_sl_max_pct}% TP ${body.atr_tp_min_pct}~${body.atr_tp_max_pct}%\n` +
    `래칫: ${body.ratchet_tiers || '(OFF·단일어깨)'}\n` +
    `당일 다단트레일: ${body.daily_trail_tiers || '(미설정)'}\n\n` +
    `저장: config_short (SHORT·tail_engine·tail_param_search 와 동일 키)`)) return;
  fetch('/api/backtest/tail/save_config', {
    method: 'POST', headers: {'Content-Type': 'application/json'},
    body: JSON.stringify(body),
  }).then(r => r.json()).then(d => {
    if (d.error) { alert('❌ ' + d.error); return; }
    // 서버가 DB(env)에 프리셋을 누적 저장했다. 재조회 없이 즉시 드롭다운에도 반영
    btAddPresetOption('tl_ratchet_preset', $('tl_ratchet') && $('tl_ratchet').value);
    btAddPresetOption('tl_daily_trail_preset', $('tl_daily_trail_tiers') && $('tl_daily_trail_tiers').value);
    const tbl = d.saved_by_table ? Object.entries(d.saved_by_table).map(([k,v]) => k + ': ' + v.length + '키').join(', ') : '';
    alert(`✅ 저장 완료 (env_id: ${d.env_id})\n${tbl}\n키: ${d.saved_keys?.join(', ')}`);
  }).catch(e => alert('오류: ' + e));
}

// 유니버스 배지 문구 (저장 이력 / 시뮬 / 전종목)
function formatUniverseLabel(p) {
  const histSrc = p.universe_source === 'history' || p.universe_source === 'history_strict';
  if (histSrc && (p.universe_history_slots || 0) > 0) {
    const strictTag = (p.universe_timing === 'strict' || p.universe_source === 'history_strict')
      ? '·strict' : '';
    return `유니버스: 저장 이력 ${p.universe_history_slots}슬롯${strictTag}`;
  }
  if (p.universe_source === 'sim') return '유니버스: 시뮬레이션';
  if (p.universe_source === 'all') return '유니버스: 전체 ws_candles 종목';
  return '유니버스: 시뮬레이션(폴백)';
}

function runBacktest() {
  const params = {
    start:        $('bt_start').value,
    end:          $('bt_end').value,
    rsi_period:   $('bt_rsi_period').value,
    rsi_oversold: $('bt_rsi_oversold').value,
    rsi_overbought: $('bt_rsi_overbought').value,
    sl_pct:       $('bt_sl').value,
    tp_pct:       $('bt_tp').value,
    tp_max_pct:   $('bt_tp_max').value,
    drop_rate:    $('bt_drop').value,
    slot_money:   $('bt_slot').value,
    cooldown_min: $('bt_cooldown').value,
    vol_mult:     $('bt_vol_mult').value,
    shoulder_min_high: $('bt_smin').value,
    shoulder_cut_pct:  $('bt_scut').value,
    time_start:   $('bt_time_start').value,
    time_end:     $('bt_time_end').value,
    max_daily:    $('bt_max_daily').value,
    high_chase_thr:  $('bt_high_chase').value,
    max_daily_chg:   $('bt_max_daily_chg').value,
    min_price:       $('bt_min_price').value,
    max_loss_krw:    $('bt_max_loss_krw').value,
    min_margin:      $('bt_min_margin').value,
    use_defense_filters: $('bt_use_defense')?.checked ? 1 : 0,
    use_macd_cross: $('bt_use_macd')?.checked ? 1 : 0,
    force_eod_exit: 0, // 실매 정렬 기본: 마지막봉 강제청산 OFF
  };
  params.universe = $('bt_use_univ_history')?.checked ? 'history' : 'sim';
  const qs = new URLSearchParams(params).toString();
  showSpinner(true);
  fetch('/api/backtest/scalping?' + qs)
    .then(r => r.json())
    .then(d => { showSpinner(false); renderBacktest(d); })
    .catch(err => { showSpinner(false); alert('오류: ' + err); });
}

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

  $('bt_params_bar').style.display = '';
  const universeLabel = formatUniverseLabel(p);
  const tr = (v) => (typeof v === 'number' ? Number(v).toFixed(1) : v);
  const btSummaryLine =
    `<span class="badge ${p.universe_source === 'history' ? 'badge-info' : 'badge-secondary'}" title="target_candidates_history 사용 여부">${universeLabel}</span> ` +
    `<span class="badge ${p.use_defense_filters ? 'badge-info' : 'badge-secondary'}">방어:${p.use_defense_filters ? 'ON' : 'OFF'}</span> ` +
    `<span class="badge ${p.use_macd_cross ? 'badge-warning' : 'badge-secondary'}">MACD:${p.use_macd_cross ? 'ON' : 'OFF'}</span> ` +
    `<span class="badge ${p.force_eod_exit ? 'badge-warning' : 'badge-secondary'}">EOD강제:${p.force_eod_exit ? 'ON' : 'OFF'}</span> ` +
    `RSI(${p.rsi_period}) &lt;${p.rsi_oversold} / &gt;${p.rsi_overbought ?? 75} 과열차단 | ` +
    `손절-${tr(p.sl_pct)}% 익절+${tr(p.effective_tp_pct ?? p.tp_pct)}%(상한${tr(p.tp_max_pct)}%) | ` +
    `낙폭≥${tr(p.drop_rate)}% | 쿨다운${p.cooldown_min}분 | ` +
    `거래량${tr(p.vol_mult)}배 | 어깨(${tr(p.shoulder_min_high)}%발동/${tr(p.shoulder_cut_pct)}%하락) | ` +
    `${p.time_window} | 일${p.max_daily}회 | ` +
    `종목수 ${p.codes_analyzed}개`;
  $('bt_params_bar').innerHTML = btSummaryLine;
  const ctx = $('bt_trade_context');
  if (ctx) {
    ctx.innerHTML = '📋 ' + btSummaryLine;
    ctx.style.display = '';
  }

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

  $('b_pnl').textContent = fmtKrw(s.total_pnl);
  colorPnl($('b_pnl'), s.total_pnl);

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

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

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

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

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

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

  renderVirtualTrades('bt_tbody', d.trades || [], { showRsi: true });
}

// ────────────────────────────────────────────
// DBBAND — 종목별 더블 볼린저 (UPDOW와 동일: 종목 1개씩 파라미터)
// ────────────────────────────────────────────
function dbSyncExitModeUi() {
  const mode = ($('db_exit_mode') && $('db_exit_mode').value) || 'classic';
  const isV4 = mode === 'v4_scalp';
  const mh = $('db_max_hold_wrap');
  if (mh) mh.style.opacity = isV4 ? '1' : '0.45';
  if ($('db_max_hold') && !isV4 && ($('db_max_hold').value === '16' || $('db_max_hold').value === '')) {
    $('db_max_hold').value = '0';
  }
}

function fillDbBandFormFromApi(b) {
  if (!b || !Object.keys(b).length) return;
  const set = (id, val) => { if (val != null && $(id)) $(id).value = val; };
  set('db_period', b.bb_period);
  set('db_inner_std', b.bb_inner_std);
  set('db_outer_std', b.bb_outer_std);
  set('db_trend_ma', b.trend_ma_period);
  set('db_sl', b.sl_pct);
  set('db_tp', b.tp_pct);
  set('db_rr', b.rr_ratio);
  set('db_valid_bars', b.entry_valid_bars);
  set('db_max_hold', b.max_hold_bars);
  set('db_slot', b.slot_money);
  if (b.timeframe) set('db_tf', b.timeframe);
  if (b.tp_mode && $('db_tp_mode')) $('db_tp_mode').value = b.tp_mode;
  if (b.exit_mode && $('db_exit_mode')) $('db_exit_mode').value = b.exit_mode;
  if ($('db_use_trend') && b.use_trend_filter !== undefined) {
    $('db_use_trend').checked = !!b.use_trend_filter;
  }
  dbSyncExitModeUi();
}

function dbCollectFormBody() {
  return {
    code: $('db_code')?.value || '',
    tf_min: parseInt($('db_tf').value, 10),
    bb_period: parseInt($('db_period').value, 10),
    bb_inner_std: parseFloat($('db_inner_std').value),
    bb_outer_std: parseFloat($('db_outer_std').value),
    trend_ma_period: parseInt($('db_trend_ma').value, 10),
    stop_loss_pct: parseFloat($('db_sl').value),
    take_profit_pct: parseFloat($('db_tp').value),
    tp_mode: $('db_tp_mode').value,
    rr_ratio: parseFloat($('db_rr').value),
    entry_valid_bars: parseInt($('db_valid_bars').value, 10),
    max_hold_bars: parseInt($('db_max_hold').value, 10),
    slot_money: parseInt($('db_slot').value, 10),
    use_trend_filter: $('db_use_trend').checked,
    exit_mode: ($('db_exit_mode') && $('db_exit_mode').value) || 'classic',
    side_mode: 'long_only',
    entry_mode: 'break_high',
    stop_mode: 'signal_low',
  };
}

function dbSelectedMeta() {
  const sel = $('db_code');
  const opt = sel && sel.selectedOptions[0];
  const rawName = (opt && opt.textContent) || (sel && sel.value) || '';
  const name = String(rawName).replace(/\s*\[[^\]]*\]\s*/g, '').replace(/\s*\([^)]*\)\s*$/, '').trim();
  return {
    name: name || (sel && sel.value) || '',
    market_type: (opt && opt.dataset.marketType) || (opt && opt.dataset.market) || 'KR',
    exchange: (opt && opt.dataset.exchange) || 'KRX',
    symbol: (opt && opt.dataset.symbol) || (sel && sel.value) || '',
  };
}

function dbFillFormFromEngine(e) {
  if (!e) return;
  const set = (id, v) => { if ($(id) && v != null) $(id).value = v; };
  set('db_tf', e.timeframe);
  set('db_period', e.bb_period);
  set('db_inner_std', e.bb_inner_std);
  set('db_outer_std', e.bb_outer_std);
  set('db_trend_ma', e.trend_ma_period);
  set('db_sl', e.sl_pct);
  set('db_tp', e.tp_pct);
  set('db_rr', e.rr_ratio);
  set('db_valid_bars', e.entry_valid_bars);
  set('db_max_hold', e.max_hold_bars);
  set('db_slot', e.slot_money);
  if ($('db_tp_mode') && e.tp_mode) $('db_tp_mode').value = e.tp_mode;
  if ($('db_exit_mode') && e.exit_mode) $('db_exit_mode').value = e.exit_mode;
  if ($('db_use_trend')) $('db_use_trend').checked = !!e.use_trend_filter;
  dbSyncExitModeUi();
}

function dbLoadStocks() {
  const sel = $('db_code');
  if (!sel) return;
  const prev = sel.value;
  Promise.all([
    fetch('/api/dbband/stocks').then(r => r.json()),
    fetch('/api/permanent_subs').then(r => r.json()).catch(() => ({ rows: [] })),
  ])
    .then(([saved, perm]) => {
      const byCode = {};
      (saved || []).forEach(it => { byCode[it.code] = it; });
      ((perm && perm.rows) || []).forEach(r => {
        const c = r.code || r.symbol;
        if (!c || byCode[c]) return;
        byCode[c] = {
          code: c,
          name: r.note || r.name || c,
          market_type: r.market_type || 'KR',
          exchange: r.exchange || 'KRX',
          symbol: r.symbol || c,
          _candidate: true,
        };
      });
      const list = Object.values(byCode).sort((a, b) => String(a.code).localeCompare(String(b.code)));
      sel.innerHTML = '';
      if (!list.length) {
        const o = document.createElement('option');
        o.value = '';
        o.textContent = '(종목 없음 — 영구구독 등록 후 [종목저장])';
        sel.appendChild(o);
        return;
      }
      list.forEach(it => {
        const o = document.createElement('option');
        o.value = it.code;
        o.dataset.marketType = it.market_type || 'KR';
        o.dataset.exchange = it.exchange || 'KRX';
        o.dataset.symbol = it.symbol || it.code;
        const mkt = String(o.dataset.marketType).toUpperCase() === 'US'
          ? `[US:${o.dataset.exchange}] ` : '';
        const tag = it._candidate ? ' (미저장)' : '';
        o.textContent = mkt + (it.name || it.code) + ' (' + (it.symbol || it.code) + ')' + tag;
        sel.appendChild(o);
      });
      if (prev && [...sel.options].some(o => o.value === prev)) sel.value = prev;
      else if (sel.options.length) sel.selectedIndex = 0;
      sel.onchange = dbLoadConfig;
      if (sel.value) dbLoadConfig();
      if ($('db_tf') && !$('db_tf').dataset.dbBadgeBound) {
        $('db_tf').dataset.dbBadgeBound = '1';
        $('db_tf').addEventListener('change', dbRefreshMinBadge);
      }
    })
    .catch(err => alert('DBBAND 종목 로드 실패: ' + err));
}

function dbRefreshMinBadge() {
  const code = $('db_code')?.value;
  const tf = $('db_tf')?.value || '15';
  const badge = $('db_min_badge');
  if (!code || !badge) return;
  fetch(`/api/holding/min_stats?code=${encodeURIComponent(code)}&tf=${encodeURIComponent(tf)}`)
    .then(r => r.json())
    .then(st => {
      if (st.error || !st.count) {
        badge.textContent = `DB봉(${tf}분): 없음 — 분봉수집 필요`;
        badge.style.color = '#e3b341';
        return;
      }
      badge.textContent = `DB봉(${tf}분): ${st.count}봉 ${(st.min || '').slice(0, 10)}~${(st.max || '').slice(0, 10)}`;
      badge.style.color = 'var(--muted)';
    })
    .catch(() => { badge.textContent = 'DB봉: 조회실패'; });
}

function dbLoadConfig() {
  const code = $('db_code')?.value;
  if (!code) return;
  fetch('/api/dbband/config?code=' + encodeURIComponent(code))
    .then(r => r.json())
    .then(d => {
      if (d.error) { alert(d.error); return; }
      dbFillFormFromEngine(d.engine || {});
      if (d.dbband_tf_min != null) $('db_tf').value = d.dbband_tf_min;
      const ps = $('db_param_source');
      if (ps) {
        ps.textContent = d.param_source || '';
        ps.className = 'badge ' + (d.dbband_stock_saved ? 'badge-info' : 'badge-secondary');
      }
      dbRefreshMinBadge();
    })
    .catch(err => alert('설정 로드 실패: ' + err));
}

/** holding_min_candles 수집 — 백테·파라서치 공통 (UPDOW udFetchMinKiwoom 동일 API) */
function dbFetchMinKiwoom(tf) {
  tf = parseInt(String(tf != null ? tf : ($('db_tf') && $('db_tf').value) || '15'), 10) || 15;
  const code = $('db_code')?.value;
  if (!code) { alert('종목을 선택하세요'); return; }
  const selOpt = $('db_code').selectedOptions[0] || null;
  const marketType = (selOpt && selOpt.dataset.marketType) || 'KR';
  const exchange = (selOpt && selOpt.dataset.exchange) || (String(marketType).toUpperCase() === 'US' ? 'NASD' : 'KRX');
  const symbol = (selOpt && selOpt.dataset.symbol) || code;
  const name = (selOpt && selOpt.textContent) || code;
  const start = $('db_fetch_start')?.value || $('db_start')?.value;
  const end = $('db_end')?.value;
  const statusEl = $('db_min_status');
  if (!start || !end) { alert('분봉수집 시작일·종료일을 설정하세요'); return; }
  const srcLabel = String(marketType).toUpperCase() === 'US' ? 'KIS 해외' : '키움';
  if (!confirm(`🗂 ${name}(${code}) ${srcLabel} ${tf}분봉 수집\n기간: ${start} ~ ${end}\n→ holding_min_candles (백테·파라서치용)\n계속할까요?`)) return;
  if (statusEl) statusEl.textContent = '';

  fetch('/api/holding/min_candles/fetch_kiwoom', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      code, start, end, tf,
      market_type: marketType,
      exchange: exchange,
      symbol: symbol,
    }),
  })
    .then(r => r.json())
    .then(d => {
      if (d.error) { alert('❌ ' + d.error); return; }
      const jobId = d.job_id;
      function poll() {
        fetch(`/api/holding/min_candles/status/${jobId}`)
          .then(r => r.json())
          .then(j => {
            if (j.status === 'running') {
              const jtf = j.tf != null ? j.tf : tf;
              if (statusEl) statusEl.textContent = `⏳ ${srcLabel} ${jtf}분… ${j.fetched || 0} → 저장 ${j.saved || 0}봉`;
              setTimeout(poll, 2000);
            } else if (j.status === 'done') {
              const jtf = j.tf != null ? j.tf : tf;
              if (statusEl) statusEl.textContent = `✅ 완료 ${j.saved || 0}봉 (${jtf}분)`;
              if ($('db_tf')) $('db_tf').value = String(jtf);
              dbRefreshMinBadge();
            } else {
              if (statusEl) statusEl.textContent = '❌ ' + (j.error || j.status || '오류');
            }
          })
          .catch(() => setTimeout(poll, 3000));
      }
      poll();
    })
    .catch(err => alert('오류: ' + err));
}

function runDbBandBacktest() {
  const code = $('db_code')?.value;
  if (!code) { alert('종목을 선택하거나 [종목저장]으로 등록하세요.'); return; }
  const qs = new URLSearchParams({
    code,
    start: $('db_start').value,
    end: $('db_end').value,
    tf: $('db_tf').value,
    sl_pct: $('db_sl').value,
    tp_pct: $('db_tp').value,
    tp_mode: $('db_tp_mode').value,
    rr_ratio: $('db_rr').value,
    bb_period: $('db_period').value,
    bb_inner_std: $('db_inner_std').value,
    bb_outer_std: $('db_outer_std').value,
    trend_ma_period: $('db_trend_ma').value,
    entry_valid_bars: $('db_valid_bars').value,
    max_hold_bars: $('db_max_hold').value,
    slot_money: $('db_slot').value,
    use_trend_filter: $('db_use_trend').checked ? '1' : '0',
    exit_mode: ($('db_exit_mode') && $('db_exit_mode').value) || 'classic',
  });
  showSpinner(true);
  fetch('/api/dbband/backtest?' + qs.toString())
    .then(r => r.json())
    .then(d => { showSpinner(false); renderDbBandBacktest(d); })
    .catch(err => { showSpinner(false); alert('오류: ' + err); });
}

function renderDbBandBacktest(d) {
  if (!d || d.error) {
    alert('❌ ' + (d && d.error ? d.error : '백테스트 응답 없음'));
    return;
  }
  if (!d.summary) {
    alert('백테스트 응답 오류 (summary 없음)');
    return;
  }
  const s = d.summary || {};
  const trades = d.trades || [];
  const p = d.params || {};

  const dbRa = $('db_result_area');
  if (dbRa) dbRa.style.display = 'block';
  const dbPb = $('db_params_bar');
  if (dbPb) dbPb.style.display = 'block';

  const setTxt = (id, txt) => { const el = $(id); if (el) el.textContent = txt; };
  const sign = v => (v > 0 ? '+' : '');

  const srcBadge = p.param_source === 'dbband_stock_config'
    ? 'badge-info'
    : 'badge-secondary';
  const srcLabel = p.param_source === 'dbband_stock_config'
    ? '종목저장'
    : (p.param_source || d.param_source || 'env-fallback');
  const trendLab = p.use_trend_filter ? `추세MA${p.trend_ma_period}` : '추세필터OFF';
  const exitLab = (p.exit_mode === 'v4_scalp') ? '청산V4' : '청산영상원형';
  const tpLab = p.tp_mode === 'rr' ? `RR${p.rr_ratio}` : (p.tp_mode || 'opposite_band');
  const periodHint = (p.start && p.end) ? ` | ${p.start}~${p.end}` : '';
  const dbSummaryLine =
    `<span class="badge ${srcBadge}" title="dbband_stock_config">${p.name || d.code}(${p.code || d.code})</span> ` +
    `BB ${p.bb_period}/${p.bb_inner_std}σ·${p.bb_outer_std}σ | ${trendLab} | ${exitLab} | ` +
    `손절-${p.sl_pct}% 익절+${p.tp_pct}% (${tpLab}) | ` +
    `어깨컷(${p.shoulder_min_high}%발동/${p.shoulder_cut_pct}%하락) | ` +
    `유효${p.entry_valid_bars}봉·최대${p.max_hold_bars}봉${periodHint} | ` +
    `1회 ${Number(p.slot_money || 0).toLocaleString()}원 · 동시${p.max_stocks || 1}종 · 한도 ${Number(p.total_budget_krw || 0).toLocaleString()}원 | ` +
    `쿨다운${p.cooldown_min}분 | 일${p.max_daily || '—'}회 | ${d.tf || p.timeframe}분봉 ${d.candle_count || p.candle_count || '—'}봉 · ${srcLabel}`;

  if (dbPb) dbPb.innerHTML = dbSummaryLine;

  const psEl = $('db_param_source');
  if (psEl) {
    psEl.textContent = srcLabel;
    psEl.className = 'badge ' + (p.param_source === 'dbband_stock_config' ? 'badge-info' : 'badge-secondary');
    psEl.style.fontSize = '11px';
  }

  setTxt('db_total', (s.total_trades || 0) + '건');
  setTxt('db_winrate', (s.win_rate || 0) + '%');
  colorPnl($('db_winrate'), (s.win_rate || 0) - 50);

  setTxt('db_pnl', fmtKrw(s.total_pnl));
  colorPnl($('db_pnl'), s.total_pnl);

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

  setTxt('db_mdd', '-' + fmtWon(s.max_drawdown) + '원');
  setTxt('db_hold', (s.avg_hold_min || 0) + '분');

  setTxt('db_bot_pct', sign(s.bot_pct || 0) + (s.bot_pct || 0) + '%');
  colorPnl($('db_bot_pct'), s.bot_pct || 0);
  setTxt('db_daily_avg_pct', sign(s.daily_avg_pct || 0) + (s.daily_avg_pct || 0) + '%');
  colorPnl($('db_daily_avg_pct'), s.daily_avg_pct || 0);

  setTxt('db_candles', d.candle_count != null ? d.candle_count : (p.candle_count || '-'));

  if (s.budget_warning) {
    if (dbPb) dbPb.innerHTML += `<div class="mt-1" style="color:#e3b341;font-size:12px">💰 ${s.budget_warning}</div>`;
  }
  if ((s.total_trades || 0) === 0) {
    const zmsg = s.budget_warning
      ? `거래 0건 — ${s.budget_warning}`
      : '거래 0건 — 기간·BB이탈·복귀 패턴·추세필터를 확인하세요.';
    const zhtml = `<div class="mt-2 p-2 rounded" style="color:#f85149;font-size:13px;border:1px solid #f85149;background:rgba(248,81,73,.08)">⚠️ ${zmsg}</div>`;
    if (dbPb) dbPb.innerHTML += zhtml;
  }

  const winbar = $('db_winbar_card');
  if ((s.total_trades || 0) > 0) {
    if (winbar) winbar.style.display = 'block';
    const wr = s.win_rate || 0;
    setTxt('db_win_label', `🟢 승 ${s.win_trades || 0}건 (${wr}%)`);
    setTxt('db_loss_label', `🔴 패 ${s.loss_trades || 0}건 (${(100 - wr).toFixed(1)}%)`);
    const rg = $('db_ratio_g'); if (rg) rg.style.width = wr + '%';
    const rr = $('db_ratio_r'); if (rr) rr.style.width = (100 - wr) + '%';
  } else if (winbar) {
    winbar.style.display = 'none';
  }

  if (s.bnh_pct !== undefined && s.bnh_pct !== null) {
    const bnhRow = $('db_bnh_row');
    if (bnhRow) bnhRow.style.display = '';
    setTxt('db_bot_pct_bnh', sign(s.bot_pct || 0) + (s.bot_pct || 0) + '%');
    colorPnl($('db_bot_pct_bnh'), s.bot_pct || 0);
    setTxt('db_bnh_pct', sign(s.bnh_pct) + s.bnh_pct + '%');
    colorPnl($('db_bnh_pct'), s.bnh_pct);
    setTxt('db_bnh_pnl', sign(s.bnh_pnl) + fmtKrw(s.bnh_pnl));
    colorPnl($('db_bnh_pnl'), s.bnh_pnl);
    setTxt('db_alpha', sign(s.alpha_pct) + (s.alpha_pct != null ? s.alpha_pct : 0) + '%p');
    colorPnl($('db_alpha'), s.alpha_pct || 0);
  } else {
    const bnhRow = $('db_bnh_row');
    if (bnhRow) bnhRow.style.display = 'none';
  }

  try {
    if (d.equity && d.equity.length) {
      lineChart('db_equity_chart',
        d.equity.map(e => e.date),
        d.equity.map(e => e.cum_pnl),
        '가상누적손익', '#3fb950');
    } else {
      destroyChart('db_equity_chart');
    }

    const rKeys = Object.keys(d.reasons || {});
    if (rKeys.length) {
      doughnutChart('db_reason_chart', rKeys, rKeys.map(k => d.reasons[k]));
    } else {
      destroyChart('db_reason_chart');
    }
  } catch (chartErr) {
    console.error('db chart render', chartErr);
  }

  const tbody = $('db_tbody');
  if (tbody) {
    tbody.innerHTML = '';
    trades.forEach(t => {
      const tr = document.createElement('tr');
      const pnl = Number(t.pnl || 0);
      tr.innerHTML =
        `<td>${t.side || 'long'}</td><td>${t.entry_time || ''}</td><td>${t.exit_time || ''}</td>` +
        `<td>${fmtWon(t.entry)}</td><td>${fmtWon(t.exit)}</td>` +
        `<td class="${pnl >= 0 ? 'text-success' : 'text-danger'}">${fmtWon(pnl)}</td>` +
        `<td>${t.hold_min || '-'}</td><td>${t.reason || ''}</td>`;
      tbody.appendChild(tr);
    });
  }

  if (dbRa) dbRa.scrollIntoView({ behavior: 'smooth', block: 'start' });
}

function saveDbBandStockConfig() {
  const code = $('db_code')?.value;
  if (!code) { alert('종목을 선택하세요'); return; }
  const meta = dbSelectedMeta();
  const body = Object.assign(dbCollectFormBody(), { code }, meta);
  if (!confirm(`${code} 파라미터를 dbband_stock_config에 저장할까요?`)) return;
  fetch('/api/dbband/save_holding', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  })
    .then(r => r.json())
    .then(d => {
      if (d.error) alert('❌ ' + d.error);
      else {
        alert('✅ 저장 완료 (' + code + ', tf=' + d.dbband_tf_min + '분)');
        dbLoadStocks();
      }
    })
    .catch(err => alert('저장 오류: ' + err));
}

function dbApplySearchParams(p) {
  if (!p) return;
  dbFillFormFromEngine({
    bb_period: p.bb_period,
    bb_inner_std: p.bb_inner_std,
    bb_outer_std: p.bb_outer_std,
    trend_ma_period: p.trend_ma_period,
    sl_pct: p.stop_loss_pct != null ? p.stop_loss_pct : (p.sl_pct != null ? p.sl_pct * 100 : null),
    tp_pct: p.take_profit_pct != null ? p.take_profit_pct : (p.tp_pct != null ? p.tp_pct * 100 : null),
    tp_mode: p.tp_mode,
    rr_ratio: p.rr_ratio,
    entry_valid_bars: p.entry_valid_bars,
    max_hold_bars: p.max_hold_bars,
    slot_money: p.slot_money,
    use_trend_filter: p.use_trend_filter,
  });
}

function runDbBandParamSearch() {
  const code = $('db_code')?.value;
  if (!code) { alert('종목을 선택하세요'); return; }
  const qs = new URLSearchParams({
    code,
    start: $('db_start').value,
    end: $('db_end').value,
    tf: $('db_tf').value,
    top: '20',
  });
  showSpinner(true);
  fetch('/api/dbband/param_search?' + qs.toString())
    .then(r => r.json())
    .then(d => {
      showSpinner(false);
      if (d.error) { alert(d.error); return; }
      window._db_last_search = d.results || [];
      $('db_search_results').style.display = '';
      const tb = $('db_search_tbody');
      tb.innerHTML = '';
      (d.results || []).forEach((row, i) => {
        const p = row.params || {};
        const tr = document.createElement('tr');
        tr.innerHTML =
          `<td>${i + 1}</td><td>${fmtWon(row.total_pnl)}</td><td>${row.win_rate}%</td>` +
          `<td>${row.total_trades}</td><td>${p.bb_inner_std}/${p.bb_outer_std}</td>` +
          `<td>${p.trend_ma_period}</td>` +
          `<td><button type="button" class="btn btn-sm btn-outline-primary">적용</button></td>`;
        tr.querySelector('button').onclick = () => dbSaveSearchRow(row, i + 1);
        tb.appendChild(tr);
      });
    })
    .catch(err => { showSpinner(false); alert('오류: ' + err); });
}

function dbSaveSearchRow(row, rank) {
  const code = $('db_code')?.value;
  if (!code || !row) return;
  const p = row.params || {};
  dbApplySearchParams(p);
  const meta = dbSelectedMeta();
  const body = Object.assign(dbCollectFormBody(), meta, {
    code,
    bb_period: p.bb_period,
    bb_inner_std: p.bb_inner_std,
    bb_outer_std: p.bb_outer_std,
    trend_ma_period: p.trend_ma_period,
    stop_loss_pct: p.stop_loss_pct != null ? p.stop_loss_pct : (p.sl_pct != null ? p.sl_pct * 100 : undefined),
    take_profit_pct: p.take_profit_pct != null ? p.take_profit_pct : (p.tp_pct != null ? p.tp_pct * 100 : undefined),
    tp_mode: p.tp_mode,
    rr_ratio: p.rr_ratio,
    entry_valid_bars: p.entry_valid_bars,
    max_hold_bars: p.max_hold_bars,
    slot_money: p.slot_money != null ? p.slot_money : parseInt($('db_slot').value, 10),
    use_trend_filter: p.use_trend_filter != null ? p.use_trend_filter : $('db_use_trend').checked,
  });
  if (!confirm(`#${rank} 파라미터를 ${code}에 저장할까요?`)) return;
  fetch('/api/dbband/save_holding', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  })
    .then(r => r.json())
    .then(d => {
      if (d.error) alert('❌ ' + d.error);
      else {
        alert('✅ #' + rank + ' 적용·저장 완료');
        dbLoadStocks();
      }
    })
    .catch(err => alert('저장 오류: ' + err));
}

// ────────────────────────────────────────────
// 꼬리잡기 백테스트
// ────────────────────────────────────────────
function formatLimitEntryLabel(p) {
  if (!p) return '';
  const em = String(p.entry_mode || 'align').toLowerCase();
  if (em === 'limit_atr') {
    return ` · 진입 C limit_atr · ${p.limit_anchor || 'signal_low'} −ATR×${p.limit_atr_mult != null ? p.limit_atr_mult : '-'} · 유효${p.limit_valid_bars != null ? p.limit_valid_bars : 1}봉 · 슬립${p.limit_fill_slip_pct != null ? p.limit_fill_slip_pct : 0}%`;
  }
  return ' · 진입 A align(다음봉 시가)';
}

function runTailBacktest() {
  const params = {
    start:              $('tl_start').value,
    end:                $('tl_end').value,
    timeframe:          $('tl_tf').value,
    min_drop_rate:      $('tl_drop').value,
    min_recovery_ratio: $('tl_rec').value,
    tail_ratio_min:     $('tl_tail').value,
    tail_pct_min:       $('tl_tail_pct').value,
    max_rec_3m:         $('tl_max_rec_3m').value,
    sl_pct:             $('tl_sl').value,
    tp_pct:             $('tl_tp').value,
    shoulder_min_high:  $('tl_smin').value,
    shoulder_cut_pct:   $('tl_scut').value,
    high_chase_thr:     $('tl_high_chase').value,
    rsi_period:         $('tl_rsi_period').value,
    rsi_threshold:      $('tl_rsi').value,
    cooldown_min:       $('tl_cool').value,
    time_start:         $('tl_ts').value,
    time_end:           $('tl_te').value,
    max_daily:          $('tl_maxd').value,
    slot_money:         $('tl_slot').value,
    max_stocks:         $('tl_max_stocks').value,
    total_budget_krw:   $('tl_total_budget').value,
    min_price:          $('tl_min_price').value,
    max_daily_change:   $('tl_max_daily_change').value,
    ma20_max_above:     $('tl_ma20_above').value,
    max_loss_krw:       $('tl_max_loss_krw').value,
    min_drop_pct_for_loss_cut: (parseFloat($('tl_min_drop_loss_cut').value) / 100) || 0.015,
    stop_atr_mult:      $('tl_stop_atr').value,
    target_atr_mult:    $('tl_target_atr').value,
    atr_sl_min_pct:     $('tl_atr_sl_min').value,
    atr_sl_max_pct:     $('tl_atr_sl_max').value,
    atr_tp_min_pct:     $('tl_atr_tp_min').value,
    atr_tp_max_pct:     $('tl_atr_tp_max').value,
    universe:           $('tl_use_univ_history')?.checked ? 'history' : 'all',
    entry_mode:         ($('tl_entry_mode') && $('tl_entry_mode').value) || 'limit_atr',
    limit_atr_mult:     $('tl_limit_atr_mult')?.value || '1.5',
    limit_anchor:       ($('tl_limit_anchor') && $('tl_limit_anchor').value) || 'signal_low',
    limit_valid_bars:   $('tl_limit_valid_bars')?.value || '1',
    limit_fill_slip_pct: $('tl_limit_fill_slip')?.value || '0',
    skip_hts_scan_dupes: $('tl_skip_hts_dupes')?.checked !== false ? '1' : '0',
    use_intraday_drop: $('tl_use_intraday_drop')?.checked ? '1' : '0',
    use_ma20_filter: $('tl_use_ma20_filter')?.checked ? '1' : '0',
    use_rsi_filter: $('tl_use_rsi_filter')?.checked !== false ? '1' : '0',
    use_daily_range_filter: $('tl_use_daily_range')?.checked !== false ? '1' : '0',
    use_high_chase_filter: $('tl_use_high_chase_f')?.checked !== false ? '1' : '0',
    bar_chg_min_pct: $('tl_bar_chg_min')?.value || '-10',
    bar_chg_max_pct: $('tl_bar_chg_max')?.value || '-1.5',
    tail_vol_mult: $('tl_tail_vol_mult')?.value || '0',
    tail_vol_win: $('tl_tail_vol_win')?.value || '5',
    ratchet_tiers: ($('tl_ratchet') && $('tl_ratchet').value.trim()) || '',
    max_hold_bars: '0',
    trail_pct: $('tl_trail')?.value || '0',
    trail_arm_pct: $('tl_trail_arm')?.value || '0',
    backtest_use_tick_db: $('tl_use_tick_db')?.checked !== false ? '1' : '0',
    backtest_tick_fallback_ohlc: $('tl_tick_fallback_ohlc')?.checked !== false ? '1' : '0',
    pattern_hammer: $('tl_pat_hammer')?.checked !== false ? '1' : '0',
    pattern_pin: $('tl_pat_pin')?.checked ? '1' : '0',
    pattern_engulfing: $('tl_pat_engulfing')?.checked ? '1' : '0',
    pattern_piercing: $('tl_pat_piercing')?.checked ? '1' : '0',
    pattern_harami: $('tl_pat_harami')?.checked ? '1' : '0',
    pattern_doji: $('tl_pat_doji')?.checked ? '1' : '0',
    pattern_morning_star: $('tl_pat_morning_star')?.checked ? '1' : '0',
    ob_filter: $('tl_ob_filter')?.checked ? 1 : 0,
    pg_filter: $('tl_pg_filter')?.checked ? 1 : 0,
    max_spread_pct: $('tl_max_spread_pct')?.value,
    daily_trail_tiers: ($('tl_daily_trail_tiers') && $('tl_daily_trail_tiers').value.trim()) || '',
    daily_trail_drop_pct: $('tl_daily_trail_drop')?.value || '0',
    daily_trail_arm_krw: $('tl_daily_trail_arm')?.value || '0',
    daily_profit_mode: ($('tl_daily_profit_mode') && $('tl_daily_profit_mode').value) || 'trailing',
    eod_enabled: $('tl_eod_enabled')?.checked ? 1 : 0,
    eod_hm: ($('tl_eod_hm') && $('tl_eod_hm').value.trim()) || '15:25',
  };
  const qs = new URLSearchParams(params).toString();
  showSpinner(true);
  fetch('/api/backtest/tail?' + qs)
    .then(r => r.json())
    .then(d => { showSpinner(false); renderTailBacktest(d); })
    .catch(err => { showSpinner(false); alert('오류: ' + err); });
}

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

  $('tl_params_bar').style.display = '';
  const tlUniverseLabel = formatUniverseLabel(p);
  const tlSummaryLine =
    `<span class="badge ${p.universe_source === 'history' ? 'badge-info' : 'badge-secondary'}" title="target_candidates_history 사용 여부">${tlUniverseLabel}</span> ` +
    (p.timeframe != null ? `<span class="badge badge-primary" title="ws_candles 분봉">${p.timeframe}분봉</span> ` : '') +
    `낙폭≥${p.min_drop_rate}% | 회복률≥${p.min_recovery_ratio}% | 꼬리/몸통≥${p.tail_ratio_min} | ` +
    `손절-${p.sl_pct}% 익절+${p.tp_pct}% | 어깨컷(${p.shoulder_min_high}%발동/${p.shoulder_cut_pct}%하락)` +
    (Number(p.trail_pct) > 0 ? ` | 보조트레일${p.trail_pct}%` + (Number(p.trail_arm_pct) > 0 ? `(무장${p.trail_arm_pct}%)` : '') : '') + ` | ` +
    (p.max_loss_krw != null ? `최대손실컷 ${Number(p.max_loss_krw).toLocaleString()}원 | ` : '') +
    `1회투자 ${Number(p.slot_money||0).toLocaleString()}원 | 동시${p.max_stocks||3}종 | 총한도 ${Number(p.total_budget_krw||0).toLocaleString()}원 | ` +
    `RSI(${p.rsi_period})과열<${p.rsi_threshold} | 쿨다운${p.cooldown_min}분 | ${p.time_window || '930-1500'} | 일${p.max_daily || 3}회/종목 | 종목수 ${p.codes_analyzed}개 | 시각순 포트폴리오` +
    formatLimitEntryLabel(p);
  $('tl_params_bar').innerHTML = tlSummaryLine;
  const tlCtx = $('tl_trade_context');
  if (tlCtx) {
    tlCtx.innerHTML = '📋 ' + tlSummaryLine;
    tlCtx.style.display = '';
  }

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

  $('tl_pnl').textContent = fmtKrw(s.total_pnl);
  colorPnl($('tl_pnl'), s.total_pnl);

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

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

  const tlSign = v => (v > 0 ? '+' : '');
  $('tl_bot_pct').textContent = tlSign(s.bot_pct || 0) + (s.bot_pct || 0) + '%';
  colorPnl($('tl_bot_pct'), s.bot_pct || 0);
  $('tl_daily_avg_pct').textContent = tlSign(s.daily_avg_pct || 0) + (s.daily_avg_pct || 0) + '%';
  colorPnl($('tl_daily_avg_pct'), s.daily_avg_pct || 0);

  if (s.universe_warning) {
    $('tl_params_bar').innerHTML += `<div class="mt-1" style="color:#f85149;font-size:12px">⚠️ ${s.universe_warning}</div>`;
    if (tlCtx) tlCtx.innerHTML += `<div style="color:#f85149;font-size:12px">⚠️ ${s.universe_warning}</div>`;
  }
  if (s.budget_warning) {
    $('tl_params_bar').innerHTML += `<div class="mt-1" style="color:#e3b341;font-size:12px">💰 ${s.budget_warning}</div>`;
    if (tlCtx) tlCtx.innerHTML += `<div style="color:#e3b341;font-size:12px">💰 ${s.budget_warning}</div>`;
  }
  const tickMeta = s.tick_backtest || {};
  const buySrc = s.backtest_buy_source || '';
  if (buySrc === 'ws_ticks' || buySrc === 'ohlc_fallback' || tickMeta.tick_bar_coverage_pct != null) {
    const cov = tickMeta.tick_bar_coverage_pct != null ? tickMeta.tick_bar_coverage_pct : '—';
    const rows = tickMeta.ws_tick_rows_loaded != null ? Number(tickMeta.ws_tick_rows_loaded).toLocaleString() : '—';
    const srcLabel = buySrc === 'ws_ticks' ? '틱DB(ws_ticks)' : (buySrc === 'ohlc_fallback' ? 'OHLC 폴백' : buySrc);
    const tickColor = buySrc === 'ws_ticks' ? '#3fb950' : '#e3b341';
    const tickLine = `<div class="mt-1" style="color:${tickColor};font-size:12px">📊 매수재생 ${srcLabel} | 틱 ${rows}건 · 분봉커버 ${cov}%</div>`;
    $('tl_params_bar').innerHTML += tickLine;
    if (tlCtx) tlCtx.innerHTML += tickLine;
    const srcMap = s.tick_entry_sources || {};
    const srcKeys = Object.keys(srcMap);
    if (srcKeys.length) {
      const srcDetail = srcKeys.map(k => `${k}:${srcMap[k]}`).join(' · ');
      const srcLine = `<div style="color:#8b949e;font-size:11px">진입출처 ${srcDetail}</div>`;
      $('tl_params_bar').innerHTML += srcLine;
      if (tlCtx) tlCtx.innerHTML += srcLine;
    }
  }

  // 매수큐 실매형 스캔주기(초)·유니버스 디바운스(초) — 모멘텀 동일 표기
  const tlSkip = s.skip_stats || {};
  if (tlSkip.buy_queue_mode) {
    const qm = tlSkip.buy_queue_mode === 'live_scan'
      ? `매수큐 실매형 ${tlSkip.scan_sec || 10}초 · 스캔${tlSkip.scan_events ?? '—'} · 매수${tlSkip.scan_buys ?? '—'}`
      : '매수큐 분봉(레거시)';
    const um = tlSkip.universe_mode === 'scan_at'
      ? `유니버스 스캔시각·디바운스${tlSkip.universe_debounce_sec ?? 30}초`
      : (tlSkip.universe_mode === 'minute_slot' ? '유니버스 분슬롯' : '');
    const line = um ? `🔄 ${qm} | ${um}` : `🔄 ${qm}`;
    $('tl_params_bar').innerHTML += `<div class="mt-1" style="color:#8b949e;font-size:12px">${line}</div>`;
    if (tlCtx) tlCtx.innerHTML += `<div style="color:#8b949e;font-size:12px">${line}</div>`;
  }
  // 장중 누적손익 최고점(고점 도달 시각) → 트레일 익절 튜닝 참고
  const tlPeakCum = Number(s.peak_cum_pnl || 0);
  const tlPeakAt = fmtTradeTime(s.peak_cum_at || '');
  if (tlPeakCum > 0 && tlPeakAt) {
    const peakLine =
      `💡 장중 누적 최고 <b style="color:var(--green)">+${tlPeakCum.toLocaleString()}원</b> (${tlPeakAt})` +
      ` · 최종 <b>${fmtKrw(s.total_pnl)}</b>`;
    $('tl_params_bar').innerHTML += `<div class="mt-1" style="color:#8b949e;font-size:12px">${peakLine}</div>`;
    if (tlCtx) tlCtx.innerHTML += `<div style="color:#8b949e;font-size:12px">${peakLine}</div>`;
  }

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

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

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

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

  renderVirtualTrades('tl_tbody', d.trades || [], {
    showDebug: true,
    showCumulative: true,
    totalBudget: Number((d.params && d.params.total_budget_krw) || 0),
  });
}

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

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

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

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

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

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

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

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

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

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

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

function hdRunBacktest(code, name) {
  const cfg   = hdGetCfg(code);
  const start = $('hd_start').value;
  const end   = $('hd_end').value;
  const qs = new URLSearchParams();
  qs.set('code', code);
  qs.set('start', start);
  qs.set('end', end);
  Object.entries(cfg).forEach(([k, v]) => {
    if (k === 'code') return;
    if (v === undefined || v === null || Number.isNaN(v)) return;
    qs.set(k, String(v));
  });
  showSpinner(true);
  fetch('/api/holding/backtest?' + qs.toString())
    .then(r => r.json())
    .then(d => {
      showSpinner(false);
      if (d.error) { alert('❌ ' + d.error); return; }
      hdCurrentCode = code;
      hdCurrentName = name;
      hdRenderResult(d, name);
    })
    .catch(err => { showSpinner(false); alert('오류: ' + err); });
}

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

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

function hdMinTradesQs() {
  const el = $('hd_min_trades');
  if (!el) return 1;
  let v = parseInt(el.value, 10);
  if (isNaN(v)) return 1;
  return Math.max(0, Math.min(30, v));
}

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

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

function hdSaveConfig(code, name) {
  const cfg = hdGetCfg(code);
  cfg.name  = name;
  fetch(`/api/holding/config/${code}`, {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify(cfg),
  })
    .then(async r => {
      const ct = r.headers.get('content-type') || '';
      const raw = await r.text();
      if (!ct.includes('application/json')) {
        throw new Error(`HTTP ${r.status} — JSON이 아닌 응답(로그인/404/서버오류 페이지일 수 있음): ${raw.slice(0, 200)}`);
      }
      let d;
      try { d = JSON.parse(raw); } catch (e) { throw new Error('JSON 파싱 실패: ' + raw.slice(0, 120)); }
      if (!r.ok) throw new Error(d.error || d.message || `HTTP ${r.status}`);
      return d;
    })
    .then(d => {
      if (d.ok) alert(`✅ ${name}(${code}) 파라미터 저장 완료`);
      else alert('❌ ' + (d.error || '저장 실패'));
    })
    .catch(err => alert('오류: ' + err));
}

function hdApplySearchResult(code, name, row) {
  // apply_cfg: 탐색에 사용된 전체 cfg / params: 그리드만(구버전) / 그 외: row 자체가 cfg dict 인 경우
  let src;
  if (row && row.apply_cfg && typeof row.apply_cfg === 'object') {
    src = row.apply_cfg;
  } else if (row && row.params && typeof row.params === 'object') {
    src = row.params;
  } else {
    src = row;
  }
  Object.entries(src).forEach(([k, v]) => {
    if (v === undefined || v === null) return;
    const el = $(`cfg_${code}_${k}`);
    if (!el) return;
    el.value = String(v);
  });
  alert(`✅ ${name} 탐색 결과를 카드에 반영했습니다.\n💾 설정저장 버튼을 눌러 DB에 저장하세요.`);
}

function hdRenderResult(d, name) {
  const s = d.summary || {};
  $('hd_result_area').style.display   = '';
  $('hd_search_result').style.display = 'none';
  const hHint = $('hd_search_hint');
  if (hHint) { hHint.style.display = 'none'; hHint.textContent = ''; }
  const nBar = d.candle_count != null ? d.candle_count : ((d.candle_rows && d.candle_rows.length) || 0);
  $('hd_result_title').textContent    = `📈 [${name}] 백테스트 결과 (${nBar}봉)`;

  const pe = $('hd_bt_params_echo');
  if (pe) {
    if (d.params) {
      const p = d.params;
      const tfOn = Number(p.trend_filter) >= 0.5;
      pe.style.display = '';
      pe.innerHTML =
        '이번 실행에 쓰인 파라미터(서버 반영): '
        + `MA ${p.ma_fast}/${p.ma_slow} · 매수RSI≤ ${p.rsi_buy1}/${p.rsi_buy2} · RSI기간 ${p.rsi_period} · 비중 ${p.buy1_ratio}/${p.buy2_ratio}/${p.buy3_ratio}%`
        + ` · 투자금 ${fmt(p.slot_money)}원 · 추세필터 ${tfOn ? 'ON' : 'OFF'} · 샹들리에×${p.atr_mult}<br>`
        + '<span style="opacity:0.88">요약의 봇 수익률·거래 건수가 안 바뀌면, 바꾼 값이 그 구간에선 <b>진입/청산 조건을 바꾸지 못한 것</b>입니다(전일비 표와는 별개).</span>';
    } else {
      pe.style.display = 'none';
      pe.textContent = '';
    }
  }

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

  const sign = v => (v > 0 ? '+' : '');
  // Buy & Hold 비교 (전체 구간)
  if (s.bnh_pct !== undefined && s.bnh_pct !== null) {
    $('hd_bnh_row').style.display = '';
    $('hd_bot_pct').textContent  = sign(s.bot_pct||0) + (s.bot_pct||0) + '%';
    colorPnl($('hd_bot_pct'), s.bot_pct||0);
    $('hd_bnh_pct').textContent  = sign(s.bnh_pct) + s.bnh_pct + '%';
    colorPnl($('hd_bnh_pct'), s.bnh_pct);
    $('hd_bnh_pnl').textContent  = sign(s.bnh_pnl) + fmtKrw(s.bnh_pnl);
    colorPnl($('hd_bnh_pnl'), s.bnh_pnl);
    $('hd_alpha').textContent = sign(s.alpha_pct) + (s.alpha_pct != null ? s.alpha_pct : 0) + '%p';
    colorPnl($('hd_alpha'), s.alpha_pct||0);
  } else {
    $('hd_bnh_row').style.display = 'none';
  }

  const alRow = $('hd_bnh_aligned_row');
  if (alRow) {
    if (s.bnh_aligned_pct !== undefined && s.bnh_aligned_pct !== null) {
      alRow.style.display = '';
      $('hd_bnh_aligned_pct').textContent  = sign(s.bnh_aligned_pct) + s.bnh_aligned_pct + '%';
      colorPnl($('hd_bnh_aligned_pct'), s.bnh_aligned_pct);
      $('hd_bnh_aligned_pnl').textContent  = sign(s.bnh_aligned_pnl) + fmtKrw(s.bnh_aligned_pnl);
      colorPnl($('hd_bnh_aligned_pnl'), s.bnh_aligned_pnl);
      const aal = s.alpha_aligned_pct != null ? s.alpha_aligned_pct : 0;
      $('hd_alpha_aligned').textContent = sign(aal) + aal + '%p';
      colorPnl($('hd_alpha_aligned'), aal);
    } else {
      alRow.style.display = 'none';
    }
  }

  const cBlk = $('hd_candle_block');
  const cTb = $('hd_candle_tbody');
  if (cBlk && cTb) {
    const rows = d.candle_rows || [];
    if (rows.length) {
      cBlk.style.display = '';
      cTb.innerHTML = '';
      rows.forEach(r => {
        const up = '#f85149', dn = '#58a6ff', fl = 'var(--muted)';
        const cWon = r.chg_won, cPct = r.chg_pct;
        const colW = r.dir > 0 ? up : (r.dir < 0 ? dn : fl);
        const colP = colW;
        const wStr = (cWon == null) ? '—' : ((cWon > 0 ? '+' : '') + fmt(cWon));
        const pStr = (cPct == null) ? '—' : ((cPct > 0 ? '+' : '') + Number(cPct).toFixed(2) + '%');
        cTb.insertAdjacentHTML('beforeend', `<tr>
          <td>${r.dt || r.date}</td>
          <td>${fmt(r.open)}</td><td>${fmt(r.high)}</td><td>${fmt(r.low)}</td><td>${fmt(r.close)}</td>
          <td style="color:${colW};font-weight:600">${wStr}</td>
          <td style="color:${colP};font-weight:600">${pStr}</td>
        </tr>`);
      });
    } else {
      cBlk.style.display = 'none';
      cTb.innerHTML = '';
    }
  }

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

  renderVirtualTrades('hd_trade_tbody', d.trades || [], {
    meta: { code: hdCurrentCode || '', name: hdCurrentName || '' },
  });
  $('hd_result_area').scrollIntoView({behavior:'smooth'});
}

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

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

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

  const tbody = $('hd_search_tbody');
  tbody.innerHTML = '';
  (top||[]).forEach((r, idx) => {
    const p = r.params;
    const pnlCls = r.total_pnl > 0 ? 'text-pnl-pos' : 'text-pnl-neg';
    const paramCells = keys.map(k => {
      const v = p[k];
      if (v === undefined || v === null) return '<td>-</td>';
      // % 단위 컬럼은 % 표시
      const isPct = k.includes('pct') || k.includes('rate');
      return `<td>${isPct ? v+'%' : v}</td>`;
    }).join('');
    const rowJson = JSON.stringify(r).replace(/</g, '\\u003c');
    tbody.insertAdjacentHTML('beforeend', `
      <tr>
        ${paramCells}
        <td class="${pnlCls}">${fmtWon(r.total_pnl)}</td>
        <td>${r.win_rate}%</td><td>${r.total_trades}</td>
        <td>${r.pf}</td><td>${r.avg_hold}일</td>
        <td><button class="btn btn-xs btn-outline-success" style="font-size:11px;padding:1px 6px"
            onclick='hdApplySearchResult("${code}","${name}",${rowJson})'>적용</button></td>
      </tr>`);
  });
  const hint = $('hd_search_hint');
  if (hint) {
    hint.style.display = '';
    let metaHtml = '';
    if (meta && meta.cartesian_product != null) {
      const fk = (meta.fixed_param_keys || []).map(fixedLabel).join(', ');
      const gk = (meta.grid_keys || []).map(fixedLabel).join(', ');
      const mtNote = meta.min_trades === 0 ? '(필터 없음)' : `≥${meta.min_trades}`;
      metaHtml =
        `<br><span style="color:var(--accent)">탐색 동작 요약:</span> 전체 그리드 조합 <b>${meta.cartesian_product}</b>개 중 `
        + `백테스트 실행 <b>${meta.backtests_run}</b>회 → TOP 후보 <b>${meta.passed}</b>개만 표시(최소거래 ${mtNote}). `
        + `제외: MA무효 <b>${meta.skipped_invalid_ma}</b>, RSI순서무효 <b>${meta.skipped_invalid_rsi_order}</b>, `
        + `백테오류 <b>${meta.skipped_backtest_error}</b>, 거래수부족 <b>${meta.skipped_below_min_trades}</b>.<br>`
        + `<span style="color:var(--muted)">그리드에서 바꾸는 항목:</span> ${gk}. `
        + `<span style="color:var(--muted)">카드값으로 고정(탐색 안 함):</span> ${fk || '—'}.<br>`
        + `<b>실매매 봇</b>은 DB에 저장된 종목 설정만 사용합니다. TOP을 쓰려면 <b>[적용] → [저장]</b> 하세요.<br>`
        + `<span style="color:var(--muted)">탐색 base</span>는 <b>DB 최근 저장</b> 전체에 카드 입력을 덮어씁니다(비중1·2·3·투자금이 추세BT와 같게 맞춰짐).`;
    }
    hint.innerHTML =
      '표의 숫자는 각 행마다 <b>다른 파라미터 조합</b>으로 돌린 백테스트 결과입니다(고정이 아님). '
      + 'ATH낙폭% 등은 그리드에 포함된 경우에만 바뀝니다.<br>'
      + 'Buy &amp; Hold 비교는 백테스트 화면의 추세BT 결과를 참고하세요.'
      + metaHtml;
  }
  if ((!top || !top.length) && meta && $('hd_search_hint')) {
    $('hd_search_hint').innerHTML += '<br><span style="color:#f85149">TOP이 비었습니다. 위 「탐색 최소거래」를 0 또는 1로 낮추거나, 백테스트 기간을 넓혀 보세요.</span>';
  }
  $('hd_result_area').scrollIntoView({behavior:'smooth'});
}

// ────────────────────────────────────────────
// 운영 설정 탭 (live_config)
// ────────────────────────────────────────────
let _lcTimer = null;
let _lcData = null;

function lcInitDate() {
  const el = $('lc_date');
  if (!el) return;
  if (!el.value) {
    const t = new Date();
    el.value = t.getFullYear() + '-'
      + String(t.getMonth() + 1).padStart(2, '0') + '-'
      + String(t.getDate()).padStart(2, '0');
  }
}

function lcOnTabShow() {
  lcInitDate();
  lcLoad();
  lcStartAutoRefresh();
}

function lcStartAutoRefresh() {
  if (_lcTimer) clearInterval(_lcTimer);
  _lcTimer = setInterval(() => {
    const tab = document.querySelector('[data-tab].active');
    if (!tab || tab.dataset.tab !== 'liveconfig') return;
    if ($('lc_auto_refresh') && !$('lc_auto_refresh').checked) return;
    lcLoad(true);
  }, 30000);
}

function lcEsc(s) {
  return String(s == null ? '' : s)
    .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
}

function lcRenderStatus(status) {
  const box = $('lc_status_cards');
  if (!box || !status) return;
  const g = status.global || {};
  const haltedCls = g.buy_halted ? 'badge-loss' : 'badge-win';
  const haltedTxt = g.buy_halted ? '매수중단' : '매수허용';
  let html = `
    <div class="col-md-4">
      <div class="card p-3 stat-card">
        <div class="stat-label">총합 봇 실현</div>
        <div class="stat-value ${dashPnlCls(g.realized_pnl_krw)}">${dashFmtKrw(g.realized_pnl_krw)}</div>
        <div style="font-size:12px;color:var(--muted)">
          목표 ${dashFmtKrw(g.target_krw)} / ${Number(g.target_pct || 0).toFixed(2)}%
          · 한도 ${dashFmtKrw(g.budget_krw)}
          · <span class="${haltedCls}">${haltedTxt}</span>
        </div>
      </div>
    </div>`;
  (status.strategies || []).forEach(row => {
    if (!row.enabled) return;
    const subHalt = row.buy_halted ? 'badge-loss">중단' : 'badge-win">허용';
    html += `
    <div class="col-md-4 col-lg-3">
      <div class="card p-3" style="font-size:13px">
        <b>${lcEsc(row.label)}</b> <code style="font-size:11px">${lcEsc(row.strategy_id)}</code>
        <div class="${dashPnlCls(row.realized_pnl_krw)}">${dashFmtKrw(row.realized_pnl_krw)}</div>
        <div style="color:var(--muted);font-size:11px">
          ${Number(row.return_pct || 0).toFixed(2)}% · <span class="${subHalt}</span>
        </div>
      </div>
    </div>`;
  });
  box.innerHTML = html;
}

function lcFieldInput(f) {
  const id = 'lc_' + f.key;
  const hint = f.hint ? `<div class="lc-field-hint">${lcEsc(f.hint)}</div>` : '';
  const tbl = f.table ? `<code class="lc-field-tbl">${lcEsc(f.table)}</code>` : '';
  const keyTag = f.key ? `<code class="lc-field-key">${lcEsc(f.key)}</code>` : '';
  if (f.type === 'bool') {
    const chk = f.value ? 'checked' : '';
    return `<div class="lc-field">
      <label class="lc-field-label"><input type="checkbox" data-lc-key="${lcEsc(f.key)}" data-lc-type="bool" id="${id}" ${chk}>
        ${lcEsc(f.label)} ${keyTag}</label> ${tbl}${hint}
    </div>`;
  }
  if (f.type === 'text') {
    const val = f.value != null ? f.value : '';
    return `<div class="lc-field">
      <label class="lc-field-label" for="${id}">${lcEsc(f.label)} ${keyTag}</label> ${tbl}
      <input type="text" class="form-control form-control-sm" data-lc-key="${lcEsc(f.key)}" data-lc-type="text"
        id="${id}" value="${lcEsc(val)}" placeholder="예: 005930,000660">
      ${hint}
    </div>`;
  }
  const step = f.type === 'int' ? '1' : '0.01';
  const val = f.value != null ? f.value : '';
  return `<div class="lc-field">
    <label class="lc-field-label" for="${id}">${lcEsc(f.label)} ${keyTag}</label> ${tbl}
    <input type="number" class="form-control form-control-sm" data-lc-key="${lcEsc(f.key)}" data-lc-type="${lcEsc(f.type)}"
      id="${id}" value="${lcEsc(val)}" step="${step}">
    ${hint}
  </div>`;
}

function lcRenderGroups(groups) {
  const root = $('lc_groups');
  if (!root) return;
  let html = '';
  (groups || []).forEach(g => {
    html += `<div class="card p-3 mb-3 lc-group" data-lc-group="${lcEsc(g.id)}">
      <div class="d-flex justify-content-between align-items-start mb-2">
        <div>
          <div class="card-title mb-0">${lcEsc(g.title)}</div>
          ${g.hint ? `<div style="font-size:12px;color:var(--muted)">${lcEsc(g.hint)}</div>` : ''}
        </div>
        <button type="button" class="btn btn-sm btn-outline-primary" onclick="lcSaveGroup('${lcEsc(g.id)}')">💾 이 섹션 저장</button>
      </div>
      <div class="lc-field-grid">`;
    (g.fields || []).forEach(f => { html += lcFieldInput(f); });
    html += '</div></div>';
  });
  root.innerHTML = html;
}

function lcCollectPatch(scopeGroupId) {
  const patch = {};
  const root = scopeGroupId
    ? document.querySelector(`.lc-group[data-lc-group="${scopeGroupId}"]`)
    : $('tab-liveconfig');
  if (!root) return patch;
  root.querySelectorAll('[data-lc-key]').forEach(el => {
    const key = el.dataset.lcKey;
    const typ = el.dataset.lcType || 'text';
    if (typ === 'bool') {
      patch[key] = el.checked;
    } else {
      patch[key] = el.value;
    }
  });
  return patch;
}

async function lcLoad(silent) {
  lcInitDate();
  const day = $('lc_date').value;
  if (!silent) showSpinner(true);
  try {
    const r = await fetch('/api/live_config?date=' + encodeURIComponent(day));
    const d = await r.json();
    if (!d.ok) {
      if (!silent) alert(d.error || '운영 설정 조회 실패');
      return;
    }
    _lcData = d;
    $('lc_meta').textContent = '기준일 ' + d.date + ' · 갱신 ' + (d.as_of || '');
    lcRenderStatus(d.status);
  // 자동 갱신: 손익·익절 상태만 — 폼 입력값은 유지
    if (!silent || !$('lc_groups') || !$('lc_groups').children.length) {
      lcRenderGroups(d.groups);
    }
  } catch (e) {
    if (!silent) alert('운영 설정 조회 오류: ' + e);
  } finally {
    if (!silent) showSpinner(false);
  }
}

async function lcSavePatch(patch, label) {
  if (!patch || !Object.keys(patch).length) {
    alert('저장할 값이 없습니다.');
    return;
  }
  if (!confirm('💾 ' + (label || '운영 설정') + '을(를) DB에 저장할까요?\n봇은 재시작 없이 다음 루프부터 반영됩니다.')) return;
  showSpinner(true);
  const resEl = $('lc_save_result');
  try {
    const r = await fetch('/api/live_config/save', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ patch }),
    });
    const d = await r.json();
    if (!d.ok) {
      alert(d.error || '저장 실패');
      return;
    }
    const tbl = d.saved_by_table
      ? Object.entries(d.saved_by_table).map(([t, ks]) => t + ': ' + ks.length + '키').join(' · ')
      : '';
        resEl.innerHTML = `<span style="color:#3fb950">✅ 저장 완료 (env_id=${d.env_id}) — ${tbl}</span>`;
    await lcLoad(false);
  } catch (e) {
    alert('저장 오류: ' + e);
  } finally {
    showSpinner(false);
  }
}

function lcSaveGroup(groupId) {
  lcSavePatch(lcCollectPatch(groupId), '섹션 ' + groupId);
}

function lcSaveAll() {
  lcSavePatch(lcCollectPatch(null), '전체 운영 설정');
}

// ════════════════════════════════════════════════════════════════
// 박스엔진 UPDOWN (kis_trader/engine/updown_box)
//  watchlist(sticky) 관리 + 박스 백테 + 글로벌 파라서치
// ════════════════════════════════════════════════════════════════
// 입력란 id ↔ 박스 cfg 키 매핑 (env UPDOWN_BOX_* 와 1:1)
const UBX_PARAM_FIELDS = [
  'lookback_bars', 'range_max_pct', 'bb_bw_max', 'ma_slope_tol_pct',
  'near_low_pct', 'tp_pct', 'stop_loss_pct', 'breach_buffer_pct',
  'panic_from_high_pct', 'shoulder_min_high_pct', 'shoulder_cut_pct', 'max_hold_bars',
  'breakout_follow',
];

function ubxOnTabShow() {
  // 날짜 기본값 (최근 6개월)
  const end = $('ubx_end'), start = $('ubx_start');
  if (end && !end.value) end.value = new Date().toISOString().slice(0, 10);
  if (start && !start.value) {
    const d = new Date(); d.setMonth(d.getMonth() - 6);
    start.value = d.toISOString().slice(0, 10);
  }
  ubxLoadCfg();
  ubxLoadWatch();
  ubxLoadStockList();
}

/** 종목 셀렉트(datalist) 채우기 — watchlist + 종목별 핀 등록 종목 */
function ubxLoadStockList() {
  fetch('/api/updown_box/stock_list')
    .then(r => r.json())
    .then(d => {
      const dl = $('ubx_stock_options');
      if (!dl) return;
      const items = d.items || [];
      dl.innerHTML = items.map(it => {
        const pin = it.n_overrides > 0 ? ' ★핀' : '';
        const st = it.status ? ` (${it.status})` : '';
        return `<option value="${it.code}">${it.name || it.code}${st}${pin}</option>`;
      }).join('');
    })
    .catch(e => console.warn('[ubx stock_list]', e));
}

/** 종목 선택 시 그 종목의 박스 파라미터(있으면 핀, 없으면 글로벌) → 입력란 */
function ubxLoadStockCfg(code) {
  code = String(code || '').trim();
  const badge = $('ubx_stock_cfg_badge');
  if (!/^\d{6}$/.test(code)) { if (badge) badge.innerHTML = ''; return; }
  fetch('/api/updown_box/stock_cfg?code=' + encodeURIComponent(code))
    .then(r => r.json())
    .then(d => {
      if (d.error) { if (badge) badge.innerHTML = ''; return; }
      const eff = d.effective || {};
      UBX_PARAM_FIELDS.forEach(k => {
        const el = $('ubx_' + k);
        if (el && eff[k] != null) el.value = eff[k];
      });
      const rt = $('ubx_ratchet_tiers');
      if (rt) rt.value = (eff.ratchet_tiers != null ? eff.ratchet_tiers : '');
      if (typeof ubxSyncRatchetShoulderColors === 'function') ubxSyncRatchetShoulderColors();
      const n = Object.keys(d.overrides || {}).length;
      if (badge) {
        badge.innerHTML = n > 0
          ? `<span style="color:#3fb950">★ 종목별 핀 ${n}개 적용중</span> <span class="text-muted">(저장값 로드)</span>`
          : `<span class="text-muted">글로벌 사용중 (핀 없음)</span>`;
      }
    })
    .catch(e => console.warn('[ubx stock_cfg]', e));
}

/** 현재 입력값을 [종목코드] 종목별 파라미터로 고정 저장(핀) */
function ubxSaveStockCfg() {
  const code = ($('ubx_code').value || '').trim();
  if (!/^\d{6}$/.test(code)) { alert('종목코드(6자리)를 선택/입력하세요'); return; }
  const overrides = {};
  UBX_PARAM_FIELDS.forEach(k => {
    const el = $('ubx_' + k);
    if (el && el.value !== '' && el.value != null) overrides[k] = el.value;
  });
  const rt = $('ubx_ratchet_tiers');
  if (rt) overrides.ratchet_tiers = String(rt.value || '').trim();  // '' = 래칫 OFF
  if (!confirm(code + ' 종목을 이 파라미터로 고정(핀)할까요?\n실매·백테 모두 이 종목엔 글로벌 대신 이 값이 적용됩니다.')) return;
  fetch('/api/updown_box/stock_cfg', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ code, name: '', overrides }),
  })
    .then(r => r.json())
    .then(d => {
      if (!d.ok) { alert('저장 실패: ' + (d.error || '')); return; }
      alert('✅ ' + code + ' 종목별 핀 저장 (' + (d.saved_keys || []).length + '개 파라미터)');
      ubxLoadStockList(); ubxLoadStockCfg(code);
    })
    .catch(e => alert('오류: ' + e));
}

/** 이 종목의 핀 해제 → 글로벌 복귀 */
function ubxClearStockCfg() {
  const code = ($('ubx_code').value || '').trim();
  if (!/^\d{6}$/.test(code)) { alert('종목코드(6자리)를 선택/입력하세요'); return; }
  if (!confirm(code + ' 종목별 핀을 해제하고 글로벌로 복귀할까요?')) return;
  fetch('/api/updown_box/stock_cfg', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ code, clear: '1' }),
  })
    .then(r => r.json())
    .then(d => {
      if (!d.ok) { alert('해제 실패: ' + (d.error || '')); return; }
      alert('↩ ' + code + ' 글로벌로 복귀');
      ubxLoadStockList(); ubxLoadCfg(); ubxLoadStockCfg(code);
    })
    .catch(e => alert('오류: ' + e));
}

/** 운영설정 UPDOWN_BOX_* 현재값 → 입력란 + watchlist 통계 */
function ubxLoadCfg() {
  fetch('/api/updown_box/config')
    .then(r => r.json())
    .then(d => {
      if (!d.ok) { console.warn('[ubx cfg]', d.error); return; }
      const c = d.cfg || {};
      UBX_PARAM_FIELDS.forEach(k => {
        const el = $('ubx_' + k);
        if (el && c[k] != null) el.value = c[k];
      });
      const rt = $('ubx_ratchet_tiers');
      if (rt) rt.value = (c.ratchet_tiers != null ? c.ratchet_tiers : '');
      if (typeof ubxSyncRatchetShoulderColors === 'function') ubxSyncRatchetShoulderColors();
      if ($('ubx_tf') && d.scan_tf_min) $('ubx_tf').value = String(d.scan_tf_min);
      const st = $('ubx_watch_stat');
      if (st) st.textContent = `— active ${d.watch_active} / max ${d.watch_max}`;
    })
    .catch(e => console.warn('[ubx cfg]', e));
}

/** watchlist 목록 표 */
function ubxLoadWatch() {
  const activeOnly = $('ubx_watch_active_only') && $('ubx_watch_active_only').checked;
  fetch('/api/updown_box/watchlist?active=' + (activeOnly ? '1' : '0'))
    .then(r => r.json())
    .then(d => {
      const tb = $('ubx_watch_tbody');
      if (!tb) return;
      if (!d.ok) { tb.innerHTML = `<tr><td colspan="10" class="text-danger">${d.error || '오류'}</td></tr>`; return; }
      const rows = d.rows || [];
      if (!rows.length) { tb.innerHTML = '<tr><td colspan="10" class="text-muted">등록된 관심종목 없음 (SCAN 또는 수동추가)</td></tr>'; return; }
      tb.innerHTML = rows.map(r => {
        const stColor = r.status === 'active' ? '#3fb950' : '#8b949e';
        const sig = r.last_signal_at ? String(r.last_signal_at).slice(0, 16) : '-';
        const added = r.added_at ? String(r.added_at).slice(0, 10) : '-';
        return `<tr>
          <td><b>${r.code}</b></td>
          <td>${r.name || ''}</td>
          <td style="color:${stColor}">${r.status}</td>
          <td>${r.source || ''}</td>
          <td>${Number(r.box_low || 0).toLocaleString()}</td>
          <td>${Number(r.box_high || 0).toLocaleString()}</td>
          <td>${Number(r.box_score || 0).toFixed(1)}</td>
          <td>${added}</td>
          <td>${sig}</td>
          <td class="text-nowrap">
            <button class="btn btn-sm btn-outline-info" onclick="ubxEditWatch('${r.code}', ${JSON.stringify(r.name || '')}, ${Number(r.box_low || 0)}, ${Number(r.box_high || 0)})">수정</button>
            <button class="btn btn-sm btn-outline-danger" onclick="ubxRemoveWatch('${r.code}')">삭제</button>
          </td>
        </tr>`;
      }).join('');
    })
    .catch(e => { const tb = $('ubx_watch_tbody'); if (tb) tb.innerHTML = `<tr><td colspan="10" class="text-danger">${e}</td></tr>`; });
}

function ubxAddWatch() {
  const code = ($('ubx_add_code').value || '').trim();
  if (!/^\d{6}$/.test(code)) { alert('6자리 종목코드를 입력하세요'); return; }
  const body = {
    code,
    name: ($('ubx_add_name').value || '').trim(),
    box_low: parseFloat($('ubx_add_low').value || '0') || 0,
    box_high: parseFloat($('ubx_add_high').value || '0') || 0,
  };
  fetch('/api/updown_box/watchlist/add', {
    method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
  })
    .then(r => r.json())
    .then(d => {
      if (!d.ok) { alert('추가 실패: ' + (d.error || '한도초과/보유중')); return; }
      $('ubx_add_code').value = ''; $('ubx_add_name').value = '';
      $('ubx_add_low').value = ''; $('ubx_add_high').value = '';
      ubxLoadWatch(); ubxLoadCfg();
    })
    .catch(e => alert('오류: ' + e));
}

/** 수정: 행 값을 입력폼에 채워 넣음 → 고친 뒤 '추가/수정' 누르면 upsert 갱신 */
function ubxEditWatch(code, name, low, high) {
  $('ubx_add_code').value = code || '';
  $('ubx_add_name').value = name || '';
  $('ubx_add_low').value = low || '';
  $('ubx_add_high').value = high || '';
  const c = $('ubx_add_code');
  if (c) { c.scrollIntoView({ behavior: 'smooth', block: 'center' }); c.focus(); }
}

function ubxRemoveWatch(code) {
  if (!confirm(code + ' 관심종목을 삭제할까요?')) return;
  fetch('/api/updown_box/watchlist/remove', {
    method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code }),
  })
    .then(r => r.json())
    .then(d => { if (!d.ok) alert('삭제 실패: ' + (d.error || '')); ubxLoadWatch(); ubxLoadCfg(); })
    .catch(e => alert('오류: ' + e));
}

/** 입력란 → 백테/파라서치 쿼리 (빈값 제외, env 오버라이드) */
function ubxCollectParamQs(qs) {
  UBX_PARAM_FIELDS.forEach(k => {
    const el = $('ubx_' + k);
    if (el && el.value !== '' && el.value != null) qs.append(k, String(el.value));
  });
  const rt = $('ubx_ratchet_tiers');  // 다단 래칫(문자열) — 숫자 필드와 별도
  if (rt && rt.value != null && String(rt.value).trim() !== '') {
    qs.append('ratchet_tiers', String(rt.value).trim());
  }
}

// 래칫/어깨컷 입력칸 색상 토글 — TAIL(tlSyncRatchetShoulderColors)과 동일.
// 다단 래칫 값이 있으면 단일 어깨컷(어깨발동·어깨폭)은 무시됨 → 빨강 표시.
function ubxSyncRatchetShoulderColors() {
  const r = $('ubx_ratchet_tiers');
  if (!r) return;
  const smin = $('ubx_shoulder_min_high_pct');
  const scut = $('ubx_shoulder_cut_pct');
  const ratchetOn = !!(r.value && String(r.value).trim());
  const RED = '#f85149';
  const paint = (el, on) => {
    if (!el) return;
    if (on) {
      el.style.setProperty('border-color', RED, 'important');
      el.style.setProperty('background-color', 'rgba(248,81,73,0.12)', 'important');
      el.style.setProperty('box-shadow', '0 0 0 1px ' + RED, 'important');
    } else {
      el.style.removeProperty('border-color');
      el.style.removeProperty('background-color');
      el.style.removeProperty('box-shadow');
    }
  };
  // 래칫 ON → 어깨컷 빨강(무시됨) / 래칫 OFF → 래칫 빨강(OFF, 어깨컷이 트레일)
  paint(smin, ratchetOn);
  paint(scut, ratchetOn);
  paint(r, !ratchetOn);
}

function ubxRunBacktest() {
  const code = ($('ubx_code').value || '').trim();
  if (!/^\d{6}$/.test(code)) { alert('종목코드(6자리)를 입력하세요'); return; }
  const qs = new URLSearchParams({
    code, tf: $('ubx_tf').value || '15',
    start: $('ubx_start').value || '', end: $('ubx_end').value || '',
  });
  ubxCollectParamQs(qs);
  $('ubx_status').textContent = '백테 실행 중…';
  showSpinner(true);
  fetch('/api/updown_box/backtest?' + qs.toString())
    .then(r => r.json())
    .then(d => {
      showSpinner(false);
      if (d.error) { $('ubx_status').textContent = ''; alert(d.error); return; }
      $('ubx_status').textContent = '';
      $('ubx_ps_card').style.display = 'none';
      ubxRenderSummary(d);
      ubxRenderTrades(d.trades || [], `${d.code} · ${d.tf}분 · ${d.candle_count}봉`, { code: d.code, name: d.name || '' });
    })
    .catch(e => { showSpinner(false); $('ubx_status').textContent = ''; alert('오류: ' + e); });
}

function ubxRenderSummary(d) {
  const card = $('ubx_summary_card'), box = $('ubx_summary');
  if (!card || !box) return;
  const bn = d.box_now || {};
  const boxBadge = bn.is_box
    ? `<span style="color:#3fb950">박스권 ✔</span> (범위 ${Number(bn.range_pct || 0).toFixed(1)}% · 밴드폭 ${bn.bb_bw != null ? Number(bn.bb_bw).toFixed(1) : '-'}% · 기울기 ${bn.ma_slope != null ? Number(bn.ma_slope).toFixed(2) : '-'}%)`
    : `<span style="color:#f0883e">비박스</span> (${bn.reason || ''})`;
  const bh = (d.buy_hold_pct != null) ? Number(d.buy_hold_pct) : null;
  const alpha = (d.alpha_pct != null) ? Number(d.alpha_pct) : null;
  box.innerHTML = `
    <div class="row g-3" style="font-size:13px">
      <div class="col-auto">거래 <b>${d.n_trades}</b></div>
      <div class="col-auto">봇 총손익 <b style="color:${d.total_pnl >= 0 ? '#3fb950' : '#f85149'}">${d.total_pnl}%</b></div>
      <div class="col-auto">PF <b>${d.pf}</b></div>
      <div class="col-auto">승률 <b>${d.win_rate}%</b></div>
      ${bh != null ? `<div class="col-auto">단순보유(B&H) <b style="color:${bh >= 0 ? '#3fb950' : '#f85149'}">${bh.toFixed(2)}%</b></div>` : ''}
      ${alpha != null ? `<div class="col-auto">초과수익(α) <b style="color:${alpha >= 0 ? '#3fb950' : '#f85149'}">${alpha >= 0 ? '+' : ''}${alpha.toFixed(2)}%</b></div>` : ''}
    </div>
    ${alpha != null ? `<div class="mt-1" style="font-size:12px;color:${alpha >= 0 ? '#3fb950' : '#f85149'}">${alpha >= 0 ? '✔ 봇이 단순보유를 이김' : '✘ 단순보유가 더 나음 — 이 종목엔 박스전략 부적합'}</div>` : ''}
    <div class="mt-2 text-muted" style="font-size:12px">현재 박스상태(최근봉): ${boxBadge}
      ${bn.box_low ? ` · 하단 ${Number(bn.box_low).toLocaleString()} / 상단 ${Number(bn.box_high).toLocaleString()}` : ''}</div>`;
  $('ubx_summary_sub').textContent = `${d.code} · ${d.tf}분`;
  card.style.display = '';
}

function ubxRenderTrades(trades, sub, meta) {
  const card = $('ubx_trades_card');
  if (!card) return;
  meta = meta || {};
  // 박스 백테 거래(run_backtest_box) → 공통 거래목록 스키마로 변환.
  // 손익(원)은 100만원 슬롯 가정으로 환산(수익률%는 실제 백테값 그대로).
  const mapped = (trades || []).map(t => {
    const ep = Number(t.buy || 0), xp = Number(t.sell || 0);
    const qty = ep > 0 ? Math.max(1, Math.floor(1000000 / ep)) : 0;
    return {
      code: meta.code || '',
      name: meta.name || '',
      buy_price: ep,
      sell_price: xp,
      entry_time: t.entry_time,
      exit_time: t.exit_time,
      profit_rate: t.pnl_pct,
      pnl: Math.round((xp - ep) * qty),
      qty,
      sell_reason: t.reason,
      hold_min: t.bars_held,
    };
  });
  renderVirtualTrades('ubx_trades_tbody', mapped, { meta });
  const subEl = $('ubx_trades_sub');
  if (subEl) subEl.textContent = sub || '';
  card.style.display = '';
}

function ubxRunParamSearch() {
  const qs = new URLSearchParams({
    tf: $('ubx_tf').value || '15',
    start: $('ubx_start').value || '', end: $('ubx_end').value || '',
    mode: $('ubx_mode').value || 'fast',
    min_trades: $('ubx_min_trades').value || '3',
  });
  if ($('ubx_from_watchlist') && $('ubx_from_watchlist').checked) {
    qs.append('from_watchlist', '1');
  } else {
    const codes = ($('ubx_codes').value || '').trim() || ($('ubx_code').value || '').trim();
    if (!codes) { alert('탐색 종목(codes) 또는 watchlist 사용을 지정하세요'); return; }
    qs.append('codes', codes);
  }
  $('ubx_status').textContent = '파라미터 탐색 중… (조합 수에 따라 수십 초)';
  showSpinner(true);
  fetch('/api/updown_box/param_search?' + qs.toString())
    .then(r => r.json())
    .then(d => {
      showSpinner(false);
      if (d.error) { $('ubx_status').textContent = ''; alert(d.error); return; }
      $('ubx_status').textContent = '';
      $('ubx_summary_card').style.display = 'none';
      $('ubx_trades_card').style.display = 'none';
      ubxRenderParamSearch(d);
    })
    .catch(e => { showSpinner(false); $('ubx_status').textContent = ''; alert('오류: ' + e); });
}

function ubxRenderParamSearch(d) {
  const card = $('ubx_ps_card'), tb = $('ubx_ps_tbody');
  if (!card || !tb) return;
  const top = d.top || [];
  const meta = d.meta || {};
  const dropped = meta.dropped_trend || [];
  const dropTxt = dropped.length
    ? ` · 추세주 제외: ${dropped.map(x => `${x.code}(α${x.alpha}%)`).join(',')}`
    : '';
  $('ubx_ps_sub').innerHTML =
    `종목 ${(d.codes || []).length} · 조합 ${meta.cartesian_product || 0} · 통과 ${meta.passed || 0} · ${meta.search_mode || ''} · 정렬:<b>${meta.rank_by || 'alpha'}</b>`
    + ((meta.skipped_codes && meta.skipped_codes.length) ? ` · 데이터부족 제외: ${meta.skipped_codes.join(',')}` : '')
    + (dropTxt ? `<span style="color:#f0883e">${dropTxt}</span>` : '');
  if (!top.length) {
    tb.innerHTML = '<tr><td colspan="9" class="text-muted">통과한 조합 없음 (최소거래 낮추거나 기간 확대)</td></tr>';
  } else {
    tb.innerHTML = top.map((r, i) => {
      const cfgStr = Object.entries(r.apply_cfg || {}).map(([k, v]) => {
        if (k === 'ratchet_tiers') return `ratchet=${(v != null && String(v).trim()) ? v : 'OFF'}`;
        return `${k}=${v}`;
      }).join(' ');
      const cfgJson = encodeURIComponent(JSON.stringify(r.apply_cfg || {}));
      const alpha = (r.alpha_pct != null) ? Number(r.alpha_pct) : null;
      const bh = (r.buy_hold_pct != null) ? Number(r.buy_hold_pct) : null;
      return `<tr>
        <td>${i + 1}</td>
        <td style="color:${(alpha != null && alpha >= 0) ? '#3fb950' : '#f85149'}"><b>${alpha != null ? (alpha >= 0 ? '+' : '') + alpha + '%' : '-'}</b></td>
        <td style="color:${r.total_pnl >= 0 ? '#3fb950' : '#f85149'}">${r.total_pnl}%</td>
        <td class="text-muted">${bh != null ? bh + '%' : '-'}</td>
        <td>${r.pf}</td><td>${r.win_rate}%</td><td>${r.n_trades}</td>
        <td style="font-size:11px">${cfgStr}</td>
        <td><button class="btn btn-sm btn-outline-success" onclick="ubxApply('${cfgJson}')">ENV적용</button></td>
      </tr>`;
    }).join('');
  }
  card.style.display = '';
}

function ubxApply(cfgJson) {
  let cfg;
  try { cfg = JSON.parse(decodeURIComponent(cfgJson)); } catch (e) { alert('파라미터 파싱 실패'); return; }
  if (!confirm('이 파라미터를 env_config UPDOWN_BOX_* 에 적용할까요?\n' +
    Object.entries(cfg).map(([k, v]) => `${k}=${v}`).join('\n'))) return;
  fetch('/api/updown_box/apply', {
    method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apply_cfg: cfg }),
  })
    .then(r => r.json())
    .then(d => {
      if (!d.ok) { alert('적용 실패: ' + (d.error || '')); return; }
      alert('✅ env_config 적용 (id=' + d.env_id + ')\n' +
        Object.entries(d.applied || {}).map(([k, v]) => `${k}=${v}`).join('\n'));
      ubxLoadCfg();
    })
    .catch(e => alert('오류: ' + e));
}

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

// ────────────────────────────────────────────
// 최초 로드
// ────────────────────────────────────────────
loadActual();
