// ──────────────────────────────────────────── // 유틸 // ──────────────────────────────────────────── 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}
${r.code || ''}` : (r.code || '-'); const reasonHtml = `${r.reason || '-'}`; const debugHtml = r.debugTick ? `${r.debugTick}` : (r.entrySource || r.exitSource ? `${r.entrySource || '-'}→${r.exitSource || '-'}` : '-'); const cumPnlHtml = Number.isFinite(r.cumPnl) ? `${fmt(r.cumPnl)}` : '-'; const cumPctHtml = Number.isFinite(r.cumReturnPct) ? `${fmtPct(r.cumReturnPct)}` : '-'; const sellCell = r.isOpen ? '보유중' : `${fmtTradeTime(r.sellRaw)}`; const exitPxCell = r.isOpen ? `${fmt(r.xp)} 현재가` : fmt(r.xp); const pnlCell = r.isOpen ? `${fmt(r.pnl)} 평가` : fmt(r.pnl); const rowStyle = r.isOpen ? ' style="background:rgba(210,153,34,.06)"' : ''; let html = ` ${nameCell} ${fmtTradeTime(r.buyRaw)} ${sellCell} ${fmt(r.ep)} ${exitPxCell} ${r.qty} ${pnlCell} ${fmtPct(r.rateNum)}`; if (opts.showCumulative) { html += `${cumPnlHtml}${cumPctHtml}`; } html += `${r.hold}`; if (opts.showDebug) html += `${debugHtml}`; html += `${reasonHtml}`; if (opts.showRsi) html += `${r.rsi != null && r.rsi !== '' ? r.rsi : '-'}`; html += ''; 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 = '등록된 영구구독 없음'; return; } tb.innerHTML = rows.map(function (x) { const onBadge = x.enabled ? 'ON' : 'OFF'; const mkBadge = (String(x.market_type).toUpperCase() === 'US') ? 'US' : 'KR'; return '' + '' + x.code + '' + '' + mkBadge + '' + '' + (x.exchange || '') + '' + '' + (x.symbol || '') + '' + '' + (x.tf_min || '') + '' + '' + (x.ws_tr_key || '') + '' + '' + onBadge + '' + '' + (x.note || '') + '' + ' ' + '' + ''; }).join(''); } catch (e) { tb.innerHTML = '로드 실패: ' + e + ''; } } 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 ? 'ON' : 'OFF'; const sid = row.strategy_id; const pnlCls = dashPnlCls(row.realized_pnl_krw); const retCls = dashPnlCls(row.return_pct); tbody.insertAdjacentHTML('beforeend', ` ${row.label || sid}
${sid} ${onBadge} ${dashFmtKrw(row.buy_turnover_krw)} ${dashFmtKrw(row.sell_turnover_krw)} ${dashFmtKrw(row.turnover_krw)} ${row.closed_trades} ${row.open_positions} ${dashFmtKrw(row.realized_pnl_krw)} ${dashFmtKrw(row.budget_limit_krw)} ${dashFmtKrw(row.budget_used_krw)} ${dashFmtKrw(row.budget_now_krw || 0)} ${dashFmtPct(row.budget_usage_pct)} ${dashFmtPct(row.return_pct)} `); }); $('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 ''; 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 `${d}
${tm}`; } return `${d}`; } /** 실계좌 대조 시 상단 — 입금액·예수금·평가금 */ 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 = ''; 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 = '' + lbl + ''; } else if (it.sync_ok) { syncBadge = 'OK'; } else { syncBadge = '주의'; } } const codeEsc = String(it.code).replace(/'/g, "\\'"); const stratEsc = String(it.strategy || '').replace(/'/g, "\\'"); const stratLbl = it.strategy ? '' + it.strategy + '' : ''; const sellBtn = it.can_sell ? `` : '매도불가'; tbody.insertAdjacentHTML('beforeend', ` ${stratLbl} ${it.name}
${it.code} ${pfFmtBuyDate(it.buy_date)} ${it.db_qty} ${it.broker_qty == null ? '—' : it.broker_qty} ${syncBadge} ${it.buy_price > 0 ? fmtWon(it.buy_price) : '—'} ${it.broker_avg_price != null && it.broker_avg_price > 0 ? fmtWon(it.broker_avg_price) : '—'} ${fmtWon(it.current_price)} ${it.pnl_pct >= 0 ? '+' : ''}${it.pnl_pct}% ${fmtWon(it.eval_amt)} ${sellBtn} `); }); } // 보유·매도 정렬 버튼 (다른 탭과 동일 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 += ` | 장중 누적 최고 +${peak.toLocaleString()}원 (${peakAt})`; } line += ` | 최종 누적 ${fmt(s.total_pnl)}원`; actCtx.innerHTML = line; actCtx.style.display = 'block'; } const note = $('act_filter_note'); if (note) { const parts = []; if (openCnt > 0) { parts.push(`📌 보유중 ${openCnt}건 — 매수만 된 포지션(노란 행), 매도 후 trade_history 로 이동`); } if (filtered > 0) { parts.push(`⚠️ 집계 제외: 0원 강제정리 ${filtered}건`); } if (s.peak_cum_pnl > 0 && s.total_pnl < s.peak_cum_pnl) { parts.push( `💡 장중 누적이 +${fmt(s.peak_cum_pnl)}원까지 갔다가 이후 청산으로 ${fmt(s.total_pnl)}원 — ` + `누적손익 열은 매도 완료 순 합계입니다.`, ); } if (parts.length) { note.style.display = ''; note.innerHTML = parts.join('
'); } 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 = `${universeLabel} ` + `진입 ${(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 += `
💰 ${s.budget_warning}
`; } 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 += `
📊 매수재생 ${srcLabel} | 틱 ${rows}건 · 분봉커버 ${cov}%
`; } if ((s.total_trades || 0) === 0) { const zmsg = s.budget_warning ? `거래 0건 — ${s.budget_warning}` : '거래 0건 — 기간·유니버스·진입조건을 확인하세요. (1회투자 > 총한도 이면 체결 불가)'; barHtml += `
⚠️ ${zmsg}
`; } 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 = `${momUnivLabel} ` + `${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 += `
💰 ${s.budget_warning}
`; if (momCtx) momCtx.innerHTML += `
💰 ${s.budget_warning}
`; } if ((s.total_trades || 0) === 0) { const zmsg = s.budget_warning ? `거래 0건 — ${s.budget_warning}` : '거래 0건 — 기간·유니버스·진입조건을 확인하세요. (총한도 < 동시×1회투자 이면 체결 불가)'; const zhtml = `
⚠️ ${zmsg}
`; if (momPb) momPb.innerHTML += zhtml; if (momCtx) momCtx.innerHTML += zhtml; } if (s.universe_warning) { if (momPb) momPb.innerHTML += `
⚠️ ${s.universe_warning}
`; if (momCtx) momCtx.innerHTML += `
⚠️ ${s.universe_warning}
`; } 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 += `
${tickLine}
`; if (momCtx) momCtx.innerHTML += `
${tickLine}
`; } 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 += `
${line}
`; if (momCtx) momCtx.innerHTML += `
${line}
`; } const peakCum = Number(s.peak_cum_pnl || 0); const peakAt = fmtTradeTime(s.peak_cum_at || ''); if (peakCum > 0 && peakAt) { const peakLine = `💡 장중 누적 최고 +${peakCum.toLocaleString()}원 (${peakAt})` + ` · 최종 ${fmt(s.total_pnl)}원`; if (momPb) momPb.innerHTML += `
${peakLine}
`; if (momCtx) momCtx.innerHTML += `
${peakLine}
`; } 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 = '순위' + keys.map(k => `${fixedLabel(k)}`).join('') + '손익(원)승률거래PF보유(분)폼반영DB저장'; } 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 '-'; if (pctKeys.has(k) && typeof v === 'number') return `${Number(v).toFixed(2)}%`; return `${v}`; }).join(''); const rank = r.rank; tbody.insertAdjacentHTML('beforeend', ` ${rank} ${paramCells} ${fmt(r.total_pnl)} ${r.win_rate}%${r.total_trades} ${r.pf}${r.avg_hold_min != null ? r.avg_hold_min : '-'} `); }); const hint = $('mom_search_hint'); if (hint) { hint.style.display = ''; const gh = meta.grid_axis_hints && typeof meta.grid_axis_hints === 'object' ? '
' + Object.entries(meta.grid_axis_hints).map(([k, txt]) => `
${fixedLabel(k)} — ${txt}
` ).join('') : ''; hint.innerHTML = `파일: ${meta.path || '?'} · ` + `${meta.start || '?'} ~ ${meta.end || '?'} · ` + `모드 ${meta.mode || '-'} · 조합 ${meta.tested_combos || '-'} / ${meta.cartesian_product || '-'}
` + `폼반영: 입력란만 갱신 · DB저장: 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 = '순위' + keys.map(k => `${fixedLabel(k)}`).join('') + '손익(원)승률거래PF보유(분)폼반영DB저장'; 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 '-'; if (fracKeys.has(k)) return `${(Number(v) * 100).toFixed(2)}%`; if (ratioKeys.has(k)) return `${Number(v) <= 1 ? (Number(v) * 100).toFixed(0) : v}%`; return `${v}`; }).join(''); const rank = r.rank; tbody.insertAdjacentHTML('beforeend', ` ${rank} ${paramCells} ${fmt(r.total_pnl)} ${r.win_rate}%${r.total_trades} ${r.pf}${r.avg_hold_min != null ? r.avg_hold_min : '-'} `); }); const hint = $('tl_search_hint'); if (hint) { hint.style.display = ''; const gh = meta.grid_axis_hints && typeof meta.grid_axis_hints === 'object' ? '
' + Object.entries(meta.grid_axis_hints).map(([k, txt]) => `
${fixedLabel(k)} — ${txt}
` ).join('') : ''; hint.innerHTML = `파일: ${meta.path || '?'} · ` + `${meta.start || '?'} ~ ${meta.end || '?'} · TF ${meta.timeframe || 3}분 · ` + `조합 ${meta.tested_combos || '-'} / ${meta.cartesian_product || '-'}
` + `폼반영: 입력란만 갱신 · DB저장: config_short TAIL_* INSERT 후 폼 동기화` + ` (총손익≤0 은 거부). 승률·PF가 높은 조합은 순위와 다를 수 있음 — 표 순위 열 기준.` + 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 = `${universeLabel} ` + `방어:${p.use_defense_filters ? 'ON' : 'OFF'} ` + `MACD:${p.use_macd_cross ? 'ON' : 'OFF'} ` + `EOD강제:${p.force_eod_exit ? 'ON' : 'OFF'} ` + `RSI(${p.rsi_period}) <${p.rsi_oversold} / >${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 = `${p.name || d.code}(${p.code || d.code}) ` + `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 += `
💰 ${s.budget_warning}
`; } if ((s.total_trades || 0) === 0) { const zmsg = s.budget_warning ? `거래 0건 — ${s.budget_warning}` : '거래 0건 — 기간·BB이탈·복귀 패턴·추세필터를 확인하세요.'; const zhtml = `
⚠️ ${zmsg}
`; 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 = `${t.side || 'long'}${t.entry_time || ''}${t.exit_time || ''}` + `${fmt(t.entry)}${fmt(t.exit)}` + `${fmt(pnl)}` + `${t.hold_min || '-'}${t.reason || ''}`; 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 = `${i + 1}${fmt(row.total_pnl)}${row.win_rate}%` + `${row.total_trades}${p.bb_inner_std}/${p.bb_outer_std}` + `${p.trend_ma_period}` + ``; 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 = `${tlUniverseLabel} ` + (p.timeframe != null ? `${p.timeframe}분봉 ` : '') + `낙폭≥${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 += `
⚠️ ${s.universe_warning}
`; if (tlCtx) tlCtx.innerHTML += `
⚠️ ${s.universe_warning}
`; } if (s.budget_warning) { $('tl_params_bar').innerHTML += `
💰 ${s.budget_warning}
`; if (tlCtx) tlCtx.innerHTML += `
💰 ${s.budget_warning}
`; } 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 = `
📊 매수재생 ${srcLabel} | 틱 ${rows}건 · 분봉커버 ${cov}%
`; $('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 = `
진입출처 ${srcDetail}
`; $('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 += `
${line}
`; if (tlCtx) tlCtx.innerHTML += `
${line}
`; } // 장중 누적손익 최고점(고점 도달 시각) → 트레일 익절 튜닝 참고 const tlPeakCum = Number(s.peak_cum_pnl || 0); const tlPeakAt = fmtTradeTime(s.peak_cum_at || ''); if (tlPeakCum > 0 && tlPeakAt) { const peakLine = `💡 장중 누적 최고 +${tlPeakCum.toLocaleString()}원 (${tlPeakAt})` + ` · 최종 ${fmt(s.total_pnl)}원`; $('tl_params_bar').innerHTML += `
${peakLine}
`; if (tlCtx) tlCtx.innerHTML += `
${peakLine}
`; } 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 ? `✅ 일봉 ${s.candle_count}봉 (${s.candle_min||'?'} ~ ${s.candle_max||'?'})` : `⚠️ 일봉 없음`; const min60Info = s.min60_count > 0 ? `⏱ 60분봉 ${s.min60_count}봉 (${s.min60_min||'?'} ~ ${s.min60_max||'?'})` : `⏱ 60분봉 없음`; list.insertAdjacentHTML('beforeend', `
${s.name||s.code} ${s.code}
${candleInfo}
${min60Info}
▶ 추세 설정
▶ RSI 매수/매도
▶ 분할 비중(%) — 추세 ON이면 1·2차만 사용. 합 100% 권장 (탐색·추세BT·저장 동일)
▶ 샹들리에 엑시트 (추세장 청산 — 고점 − ATR × 배수)
▶ 낙폭 필터 (0=비활성 / 예: 20 → 고점 대비 20% 이상 빠진 날만 매수)
`); }); } 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}
` + '요약의 봇 수익률·거래 건수가 안 바뀌면, 바꾼 값이 그 구간에선 진입/청산 조건을 바꾸지 못한 것입니다(전일비 표와는 별개).'; } 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', ` ${r.dt || r.date} ${fmt(r.open)}${fmt(r.high)}${fmt(r.low)}${fmt(r.close)} ${wStr} ${pStr} `); }); } 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 = '' + keys.map(k => `${labelMap[k] || k}`).join('') + '손익(원)승률거래PF보유(일)적용'; 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 '-'; // % 단위 컬럼은 % 표시 const isPct = k.includes('pct') || k.includes('rate'); return `${isPct ? v+'%' : v}`; }).join(''); const rowJson = JSON.stringify(r).replace(/ ${paramCells} ${fmt(r.total_pnl)} ${r.win_rate}%${r.total_trades} ${r.pf}${r.avg_hold}일 `); }); 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 = `
탐색 동작 요약: 전체 그리드 조합 ${meta.cartesian_product}개 중 ` + `백테스트 실행 ${meta.backtests_run}회 → TOP 후보 ${meta.passed}개만 표시(최소거래 ${mtNote}). ` + `제외: MA무효 ${meta.skipped_invalid_ma}, RSI순서무효 ${meta.skipped_invalid_rsi_order}, ` + `백테오류 ${meta.skipped_backtest_error}, 거래수부족 ${meta.skipped_below_min_trades}.
` + `그리드에서 바꾸는 항목: ${gk}. ` + `카드값으로 고정(탐색 안 함): ${fk || '—'}.
` + `실매매 봇은 DB에 저장된 종목 설정만 사용합니다. TOP을 쓰려면 [적용] → [저장] 하세요.
` + `탐색 baseDB 최근 저장 전체에 카드 입력을 덮어씁니다(비중1·2·3·투자금이 추세BT와 같게 맞춰짐).`; } hint.innerHTML = '표의 숫자는 각 행마다 다른 파라미터 조합으로 돌린 백테스트 결과입니다(고정이 아님). ' + 'ATH낙폭% 등은 그리드에 포함된 경우에만 바뀝니다.
' + 'Buy & Hold 비교는 백테스트 화면의 추세BT 결과를 참고하세요.' + metaHtml; } if ((!top || !top.length) && meta && $('hd_search_hint')) { $('hd_search_hint').innerHTML += '
TOP이 비었습니다. 위 「탐색 최소거래」를 0 또는 1로 낮추거나, 백테스트 기간을 넓혀 보세요.'; } $('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, '&').replace(/
총합 봇 실현
${dashFmtKrw(g.realized_pnl_krw)}
목표 ${dashFmtKrw(g.target_krw)} / ${Number(g.target_pct || 0).toFixed(2)}% · 한도 ${dashFmtKrw(g.budget_krw)} · ${haltedTxt}
`; (status.strategies || []).forEach(row => { if (!row.enabled) return; const subHalt = row.buy_halted ? 'badge-loss">중단' : 'badge-win">허용'; html += `
${lcEsc(row.label)} ${lcEsc(row.strategy_id)}
${dashFmtKrw(row.realized_pnl_krw)}
${Number(row.return_pct || 0).toFixed(2)}% · ${lcEsc(f.hint)}
` : ''; const tbl = f.table ? `${lcEsc(f.table)}` : ''; const keyTag = f.key ? `${lcEsc(f.key)}` : ''; if (f.type === 'bool') { const chk = f.value ? 'checked' : ''; return `
${tbl}${hint}
`; } if (f.type === 'text') { const val = f.value != null ? f.value : ''; return `
${tbl} ${hint}
`; } const step = f.type === 'int' ? '1' : '0.01'; const val = f.value != null ? f.value : ''; return `
${tbl} ${hint}
`; } function lcRenderGroups(groups) { const root = $('lc_groups'); if (!root) return; let html = ''; (groups || []).forEach(g => { html += `
${lcEsc(g.title)}
${g.hint ? `
${lcEsc(g.hint)}
` : ''}
`; (g.fields || []).forEach(f => { html += lcFieldInput(f); }); html += '
'; }); 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 = `✅ 저장 완료 (env_id=${d.env_id}) — ${tbl}`; 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 ``; }).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 ? `★ 종목별 핀 ${n}개 적용중 (저장값 로드)` : `글로벌 사용중 (핀 없음)`; } }) .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 = `${d.error || '오류'}`; return; } const rows = d.rows || []; if (!rows.length) { tb.innerHTML = '등록된 관심종목 없음 (SCAN 또는 수동추가)'; 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 ` ${r.code} ${r.name || ''} ${r.status} ${r.source || ''} ${Number(r.box_low || 0).toLocaleString()} ${Number(r.box_high || 0).toLocaleString()} ${Number(r.box_score || 0).toFixed(1)} ${added} ${sig} `; }).join(''); }) .catch(e => { const tb = $('ubx_watch_tbody'); if (tb) tb.innerHTML = `${e}`; }); } 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 ? `박스권 ✔ (범위 ${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) : '-'}%)` : `비박스 (${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 = `
거래 ${d.n_trades}
봇 총손익 ${d.total_pnl}%
PF ${d.pf}
승률 ${d.win_rate}%
${bh != null ? `
단순보유(B&H) ${bh.toFixed(2)}%
` : ''} ${alpha != null ? `
초과수익(α) ${alpha >= 0 ? '+' : ''}${alpha.toFixed(2)}%
` : ''}
${alpha != null ? `
${alpha >= 0 ? '✔ 봇이 단순보유를 이김' : '✘ 단순보유가 더 나음 — 이 종목엔 박스전략 부적합'}
` : ''}
현재 박스상태(최근봉): ${boxBadge} ${bn.box_low ? ` · 하단 ${Number(bn.box_low).toLocaleString()} / 상단 ${Number(bn.box_high).toLocaleString()}` : ''}
`; $('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 || ''} · 정렬:${meta.rank_by || 'alpha'}` + ((meta.skipped_codes && meta.skipped_codes.length) ? ` · 데이터부족 제외: ${meta.skipped_codes.join(',')}` : '') + (dropTxt ? `${dropTxt}` : ''); if (!top.length) { tb.innerHTML = '통과한 조합 없음 (최소거래 낮추거나 기간 확대)'; } 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 ` ${i + 1} ${alpha != null ? (alpha >= 0 ? '+' : '') + alpha + '%' : '-'} ${r.total_pnl}% ${bh != null ? bh + '%' : '-'} ${r.pf}${r.win_rate}%${r.n_trades} ${cfgStr} `; }).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();