Files
kis_bot/static/js/backtest.js
Hwang 61c72a8a4c feat(tests): 신규 키움 웹소켓 조건검색 및 실시간 조건검색 테스트 추가
변경 사항
----
- _test_kiwoom_condition_list.py: 키움 웹소켓 조건검색 '목록조회' 기능을 단독으로 테스트하는 스크립트 추가
- _test_kiwoom_condition_realtime.py: 'momentum' 조건식을 실시간으로 등록하고 초기 매칭 종목 리스트 및 실시간 편입/이탈을 수신하는 테스트 스크립트 추가
- _verify_columnar_bitid.py, _verify_shared_e2e_breakout.py, _verify_shared_e2e.py: 공유 메모리 및 dict 간의 데이터 일관성을 검증하는 테스트 추가

영향
----
- 신규 테스트 스크립트 추가로 키움 웹소켓 API의 기능 검증 및 안정성을 높임
- 기존 기능에 대한 영향 없음

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 01:27:00 +09:00

4637 lines
208 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ────────────────────────────────────────────
// 유틸
// ────────────────────────────────────────────
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 fmtPct = n => n == null ? '-' : (n >= 0 ? '+' : '') + Number(n).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 = Number(t.buy_price ?? t.avg_price ?? t.entry ?? 0);
const xp = 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 = 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: Number(t.cum_pnl ?? 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' : '')}">${fmt(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
? `${fmt(r.xp)} <span style="color:var(--muted);font-size:10px">현재가</span>`
: fmt(r.xp);
const pnlCell = r.isOpen
? `${fmt(r.pnl)} <span style="color:var(--muted);font-size:10px">평가</span>`
: fmt(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>${fmt(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) {
const v = Number(n) || 0;
return v.toLocaleString('ko-KR') + '원';
}
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);
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 = fmt(s.total_pnl) + '원';
colorPnl($('a_pnl'), s.total_pnl);
$('a_pf').textContent = s.profit_factor >= 999 ? '∞' : s.profit_factor;
colorPnl($('a_pf'), s.profit_factor - 1);
$('a_mdd').textContent = '-' + fmt(s.max_drawdown) + '원';
$('a_hold').textContent = s.avg_hold_min + '분';
const 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 ? tb.toLocaleString() : '—'}`;
if (peak > 0 && peakAt) {
line += ` | 장중 누적 최고 <b style="color:var(--green)">+${peak.toLocaleString()}원</b> (${peakAt})`;
}
line += ` | 최종 누적 <b>${fmt(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>+${fmt(s.peak_cum_pnl)}원</b>까지 갔다가 이후 청산으로 <b>${fmt(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',
};
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),
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', fmt(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', '-' + fmt(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', fmt(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', '-' + fmt(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',
};
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',
};
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', fmt(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', '-' + fmt(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>${fmt(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);
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);
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손익 ${fmt(m.total_pnl)}원 · 승률 ${m.win_rate}% · PF ${m.pf} · ${m.total_trades}`
: `${rank}위 → 폼만 반영 (DB 미저장)\n손익 ${fmt(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}">${fmt(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손익 ${fmt(m.total_pnl)}원 · 승률 ${m.win_rate}% · PF ${m.pf} · ${m.total_trades}`
: `${rank}위 → 폼만 반영 (DB 미저장)\n손익 ${fmt(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}">${fmt(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),
};
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 = fmt(s.total_pnl) + '원';
colorPnl($('b_pnl'), s.total_pnl);
$('b_pf').textContent = s.profit_factor >= 999 ? '∞' : s.profit_factor;
colorPnl($('b_pf'), s.profit_factor - 1);
$('b_mdd').textContent = '-' + fmt(s.max_drawdown) + '원';
$('b_hold').textContent = s.avg_hold_min + '분';
if (s.total_trades > 0) {
$('bt_winbar_card').style.display = '';
const wr = s.win_rate;
$('b_win_label').textContent = `🟢 익절+손절 승 ${s.win_trades}건 (${wr}%)`;
$('b_loss_label').textContent = `🔴 패 ${s.loss_trades}건 (${(100-wr).toFixed(1)}%)`;
$('b_ratio_g').style.width = wr + '%';
$('b_ratio_r').style.width = (100 - wr) + '%';
}
lineChart('b_equity_chart',
d.equity.map(e => e.date.slice(0,4)+'-'+e.date.slice(4,6)+'-'+e.date.slice(6)),
d.equity.map(e => e.cum_pnl), '가상누적손익');
const rKeys = Object.keys(d.reasons);
doughnutChart('b_reason_chart', rKeys, rKeys.map(k => d.reasons[k]));
barChart('b_daily_chart',
d.daily.map(e => e.date.slice(5)),
d.daily.map(e => e.pnl));
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', fmt(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', '-' + fmt(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) + fmt(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>${fmt(t.entry)}</td><td>${fmt(t.exit)}</td>` +
`<td class="${pnl >= 0 ? 'text-success' : 'text-danger'}">${fmt(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>${fmt(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',
};
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 = fmt(s.total_pnl) + '원';
colorPnl($('tl_pnl'), s.total_pnl);
$('tl_pf').textContent = (s.profit_factor||0) >= 999 ? '∞' : (s.profit_factor||0);
colorPnl($('tl_pf'), (s.profit_factor||0) - 1);
$('tl_mdd').textContent = '-' + fmt(s.max_drawdown) + '원';
$('tl_hold').textContent = (s.avg_hold_min||0) + '분';
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>${fmt(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 = fmt(s.total_pnl) + '원';
colorPnl($('hd_pnl'), s.total_pnl);
$('hd_pf').textContent = (s.profit_factor||0) >= 999 ? '∞' : (s.profit_factor||0);
colorPnl($('hd_pf'), (s.profit_factor||0) - 1);
$('hd_mdd').textContent = '-' + fmt(s.max_drawdown) + '원';
$('hd_hold').textContent = (s.avg_hold_days||0) + '일';
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) + fmt(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) + fmt(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}">${fmt(r.total_pnl)}</td>
<td>${r.win_rate}%</td><td>${r.total_trades}</td>
<td>${r.pf}</td><td>${r.avg_hold}일</td>
<td><button class="btn btn-xs btn-outline-success" style="font-size:11px;padding:1px 6px"
onclick='hdApplySearchResult("${code}","${name}",${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();