// ──────────────────────────────────────────── // 유틸 // ──────────────────────────────────────────── const $ = id => document.getElementById(id); /** 서버/로컬 휴장일 YYYY-MM-DD 집합 — dashInitDate 등 조기 호출보다 먼저 선언(TDZ 방지) */ let _krHolidays = new Set(); const fmt = n => n == null ? '-' : Number(n).toLocaleString('ko-KR'); /** 원화 가격·평가금 — 소수점 없이 정수 (KIS 주식 호가 단위) */ const fmtWon = n => { if (n == null || n === '' || !Number.isFinite(Number(n))) return '—'; return Math.round(Number(n)).toLocaleString('ko-KR'); }; /** 원화 금액·손익 — 소수점 없이 정수 + '원' (대시보드·요약 카드) */ const fmtKrw = n => { const w = fmtWon(n); return w === '—' ? w : w + '원'; }; /** 달러 금액 — $1,234.56 */ const fmtUsd = n => { if (n == null || n === '' || !Number.isFinite(Number(n))) return '—'; const v = Number(n); const sign = v < 0 ? '-' : ''; return sign + '$' + Math.abs(v).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2, }); }; /** 표시용 환율 (캐시/폴백). 외부 HTTP 안 탐. */ let _displayFxRate = 0; function setDisplayFxRate(r) { const v = Number(r); if (Number.isFinite(v) && v > 0) _displayFxRate = v; } function getDisplayFxRate(fallback) { const f = Number(fallback); if (Number.isFinite(f) && f > 0) return f; if (_displayFxRate > 0) return _displayFxRate; return 0; } /** 해외 탭: $USD + (원 · 환율) — fxRate 없으면 원만 생략 */ const fmtUsdWithKrw = (usd, fxRate) => { const u = fmtUsd(usd); if (u === '—') return u; const r = getDisplayFxRate(fxRate); if (!Number.isFinite(r) || r <= 0) return u; const krw = Math.round(Number(usd) * r); return `${u} (${fmtWon(krw)}원 · ${r.toFixed(2)})`; }; /** 요약 카드용 — HTML 없이 텍스트 ($ + 원) */ const fmtUsdWithKrwText = (usd, fxRate) => { const u = fmtUsd(usd); if (u === '—') return u; const r = getDisplayFxRate(fxRate); if (!Number.isFinite(r) || r <= 0) return u; return `${u} (${fmtWon(Math.round(Number(usd) * r))}원)`; }; /** 수익률(%) — 거래내역·가상체결 테이블 공용 */ const fmtPct = n => { if (n == null || n === '' || !Number.isFinite(Number(n))) return '—'; const v = Number(n); return (v >= 0 ? '+' : '') + v.toFixed(2) + '%'; }; /** 틱 분봉커버 문구 — 구독구간(주) + 전체(참고). REST 웜업 봉 때문에 전체가 낮아 보임. */ function fmtTickCoverageLabel(tickMeta) { const m = tickMeta || {}; const sub = m.tick_bar_coverage_pct_subscribed != null ? m.tick_bar_coverage_pct_subscribed : m.tick_bar_coverage_pct; const all = m.tick_bar_coverage_pct_all; const friend = m.tick_bar_coverage_pct_friend; const codes = (m.tick_codes_with_data != null && m.tick_codes_total != null) ? `${m.tick_codes_with_data}/${m.tick_codes_total}종` : ''; let s = `구독구간 ${sub != null ? sub : '—'}%`; if (m.tick_bar_coverage_pct_traded != null) { const tn = m.tick_codes_traded != null ? `${m.tick_codes_traded}종` : ''; s += ` · 거래종목 ${m.tick_bar_coverage_pct_traded}%` + (tn ? `(${tn})` : ''); } if (all != null) s += ` · 전체 ${all}%`; if (friend != null && friend !== all) s += ` · 틱종목 ${friend}%`; if (codes) s += ` (${codes})`; return s; } /** 가상 거래 내역 — 매도/청산 시각 기준 최신순 (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) + ':00'; } 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 isUsd = String(meta.currency || '').toUpperCase() === 'USD'; const roundPx = v => { const n = Number(v); if (!Number.isFinite(n)) return 0; return isUsd ? Math.round(n * 10000) / 10000 : Math.round(n); }; const roundMoney = v => { const n = Number(v); if (!Number.isFinite(n)) return 0; return isUsd ? Math.round(n * 100) / 100 : Math.round(n); }; const ep = roundPx(t.buy_price ?? t.avg_price ?? t.entry ?? 0); const xp = roundPx(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 = roundMoney(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 || ''; const fxRate = (() => { const r = Number(t.fx_rate); if (Number.isFinite(r) && r > 0) return r; const ymd = String(t.fx_ymd || sellRaw || buyRaw || '').slice(0, 10); const map = meta.fxRates || {}; const m = Number(map[ymd]); if (Number.isFinite(m) && m > 0) return m; return getDisplayFxRate(meta.fxRate); })(); const pnlKrw = (() => { const k = Number(t.pnl_krw); if (Number.isFinite(k)) return Math.round(k); if (Number.isFinite(fxRate) && fxRate > 0) return Math.round(pnl * fxRate); return NaN; })(); return { code, name, buyRaw, sellRaw, buyKey: _tradeTimeSortKey(buyRaw), sellKey: isOpen ? _tradeTimeSortKey(buyRaw) : _tradeTimeSortKey(sellRaw), ep, xp, qty, pnl, pnlKrw, fxRate, isUsd, 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 || '', parityException: !!(t.parity_exception) || _detectParityExceptionCauses(t).length > 0, parityCause: t.parity_cause || _detectParityExceptionCauses(t).join('+'), cumPnl: (() => { const v = Number(t.cum_pnl); if (!Number.isFinite(v)) return NaN; return isUsd ? Math.round(v * 100) / 100 : Math.round(v); })(), cumReturnPct: Number(t.cum_return_pct ?? NaN), entryOb: t.entry_ob || null, exitOb: t.exit_ob || null, }; } function _detectParityExceptionCauses(t) { const causes = []; const es = String(t.entry_source || '').toLowerCase(); const xs = String(t.exit_source || '').toLowerCase(); const reason = String(t.sell_reason || t.reason || ''); const el = String(t.entry_source_label || t.entrySource || ''); const xl = String(t.exit_source_label || t.exitSource || ''); if (['ohlc_low', 'ohlc_fallback', 'ohlc_high', 'ohlc_intrabar', 'ohlc_bar'].includes(es) || /진입OHLC|OHLC 폴백|OHLC high/i.test(el)) { causes.push('진입OHLC'); } if (['ohlc_bar', 'ohlc_fallback'].includes(xs) || /청산OHLC|^OHLC$/i.test(xl)) { causes.push('청산OHLC'); } if (['eod_flat', 'wallclock_eod'].includes(xs) || /틱공백EOD/i.test(xl)) { causes.push('틱공백EOD'); } if (/ghost_purge/i.test(reason)) causes.push('ghost_purge'); return causes; } function sortVirtualTradeRows(rows, mode) { const list = [...rows]; const cmpExit = (a, b) => String(a.sellKey).localeCompare(String(b.sellKey)); const cmpBuy = (a, b) => String(a.buyKey).localeCompare(String(b.buyKey)); switch (mode) { case 'exit_asc': return list.sort(cmpExit); case 'buy_desc': return list.sort((a, b) => cmpBuy(b, a)); case 'buy_asc': return list.sort(cmpBuy); 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 fillTradePnLContext(elOrId, opts) { opts = opts || {}; const el = typeof elOrId === 'string' ? $(elOrId) : elOrId; if (!el) return; const tb = Number(opts.totalBudget || 0); const peak = Number(opts.peakCum || 0); const peakAt = opts.peakAt ? fmtTradeTime(opts.peakAt) : ''; const label = opts.label || '거래내역'; const isUsd = String(opts.currency || '').toUpperCase() === 'USD'; const fx = Number(opts.fxRate || 0); const fmtMoney = (n) => isUsd ? fmtUsdWithKrwText(n, fx) : (fmtWon(n) + '원'); let line = `📋 ${label} · 한도 ${tb > 0 ? (isUsd ? fmtUsd(tb) : fmtWon(tb) + '원') : '—'}`; if (peak > 0 && peakAt) { line += ` | 장중 누적 최고 +${fmtMoney(peak)} (${peakAt})`; } if (opts.totalPnl != null && Number.isFinite(Number(opts.totalPnl))) { const pnlHtml = isUsd ? fmtUsdWithKrw(opts.totalPnl, fx > 0 ? fx : (Number(opts.totalPnlKrw) ? (Number(opts.totalPnl) ? Number(opts.totalPnlKrw) / Number(opts.totalPnl) : fx) : fx)) : fmtKrw(opts.totalPnl); // 원 환산 금액이 별도로 있으면 그걸로 병기 if (isUsd && opts.totalPnlKrw != null && Number.isFinite(Number(opts.totalPnlKrw))) { line += ` | 최종 누적 ${fmtUsd(opts.totalPnl)} (${fmtWon(opts.totalPnlKrw)}원)`; } else { line += ` | 최종 누적 ${pnlHtml}`; } } el.innerHTML = line; el.style.display = 'block'; } 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 attachCumPnlClient(trades, totalBudget) { const out = (trades || []).map(t => Object.assign({}, t)); if (!out.length) return out; if (out.every(t => Number.isFinite(Number(t.cum_pnl)))) return out; const ordered = [...out].sort((a, b) => tradeExitSortKey(a).localeCompare(tradeExitSortKey(b))); let cum = 0; const tb = Number(totalBudget || 0); for (const t of ordered) { if (t.is_open) continue; cum += Number(t.pnl ?? t.realized_pnl ?? 0); t.cum_pnl = Math.round(cum); t.cum_return_pct = tb > 0 ? Math.round((cum / tb) * 10000) / 100 : 0; } return out; } /** KR 6자리 종목코드 */ function _normKrStockCode(code) { const d = String(code || '').replace(/\D/g, ''); if (!d) return ''; return d.length >= 6 ? d.slice(-6) : d.padStart(6, '0'); } function _tradeExtLinksHtml(r) { if (r.isUsd || !r.code) return ''; const c = _normKrStockCode(r.code); return `
N N(M)
`; } /** 호가셀 — enrich 는 ratio(L3잔량비)·or_ratio(전체잔량비). 구키 mid_ratio/whale_ratio 도 허용. */ function _obMwCellHtml(ob) { if (!ob) return '-'; const mr = Number(ob.mid_ratio != null ? ob.mid_ratio : ob.ratio); const wr = Number(ob.whale_ratio != null ? ob.whale_ratio : ob.or_ratio); const m = Number.isFinite(mr) ? mr.toFixed(2) : '-'; const w = Number.isFinite(wr) ? wr.toFixed(2) : '-'; // 잔량비(배수) — % 아님 (0.68 = 매수L3/매도L3) return `M:${m}
W:${w}`; } function _entryObCellHtml(ob) { return _obMwCellHtml(ob); } function _exitObCellHtml(ob, isOpen) { if (isOpen) return '보유중'; return _obMwCellHtml(ob); } function renderVirtualTrades(tbodyId, trades, opts) { opts = opts || {}; let src = trades || []; if (opts.showCumulative) { src = attachCumPnlClient(src, opts.totalBudget); } _virtualTradesCache[tbodyId] = src; _virtualTradesOpts[tbodyId] = opts; if (!_virtualTradesSort[tbodyId]) _virtualTradesSort[tbodyId] = 'exit_desc'; const mode = _virtualTradesSort[tbodyId]; const meta = Object.assign({}, opts.meta || {}, { currency: opts.currency || (opts.meta && opts.meta.currency) || '', fxRates: opts.fxRates || (opts.meta && opts.meta.fxRates) || {}, }); const isUsd = String(meta.currency || '').toUpperCase() === 'USD'; const defaultFx = getDisplayFxRate(meta.fxRate || opts.fxRate); const fmtPx = (n, fx) => isUsd ? fmtUsdWithKrw(n, getDisplayFxRate(fx || defaultFx)) : fmtWon(n); const fmtPnlCell = (n, fx) => isUsd ? fmtUsdWithKrw(n, getDisplayFxRate(fx || defaultFx)) : fmtWon(n); const rows = sortVirtualTradeRows( (_virtualTradesCache[tbodyId] || []).map(t => normalizeVirtualTrade(t, Object.assign({}, meta, { fxRate: defaultFx }))), 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 linksHtml = _tradeExtLinksHtml(r); const reasonHtml = `${r.reason || '-'}`; const debugHtml = r.debugTick ? `${r.debugTick}` : (r.entrySource || r.exitSource ? `${r.entrySource || '-'}→${r.exitSource || '-'}` : '-'); const rowFx = getDisplayFxRate(r.fxRate || defaultFx); const cumPnlHtml = Number.isFinite(r.cumPnl) ? `${fmtPnlCell(r.cumPnl, rowFx)}` : '-'; const cumPctHtml = Number.isFinite(r.cumReturnPct) ? `${fmtPct(r.cumReturnPct)}` : '-'; const sellCell = r.isOpen ? '보유중' : `${fmtTradeTime(r.sellRaw)}`; const exitPxCell = r.isOpen ? `${fmtPx(r.xp, rowFx)} 현재가` : fmtPx(r.xp, rowFx); const pnlCell = r.isOpen ? `${fmtPnlCell(r.pnl, rowFx)} 평가` : fmtPnlCell(r.pnl, rowFx); const isParityEx = !!r.parityException; const rowCls = [ r.isOpen ? 'trade-row-open' : '', isParityEx ? 'trade-row-parity-ex' : '', ].filter(Boolean).join(' '); const causeBadge = isParityEx ? `${r.parityCause || '예외'}` : ''; const rowStyle = r.isOpen && !isParityEx ? ' style="background:rgba(210,153,34,.06)"' : ''; const liveAttrs = r.isOpen && r.code && r.ep > 0 && r.qty > 0 ? ` data-live-code="${String(r.code).replace(/"/g, '')}" data-live-buy="${r.ep}" data-live-qty="${r.qty}" data-live-usd="${r.isUsd ? '1' : '0'}"` : ''; let html = ` ${causeBadge}${nameCell}${linksHtml} ${fmtTradeTime(r.buyRaw)} ${sellCell} ${fmtPx(r.ep, rowFx)} ${exitPxCell} ${r.qty} ${pnlCell} ${fmtPct(r.rateNum)}`; if (opts.showCumulative) { html += `${cumPnlHtml}${cumPctHtml}`; } html += `${r.hold}`; if (opts.showDebug) html += `${debugHtml}`; html += `${_entryObCellHtml(r.entryOb)}`; html += `${_exitObCellHtml(r.exitOb, r.isOpen)}`; html += `${reasonHtml}`; if (opts.showRsi) html += `${r.rsi != null && r.rsi !== '' ? r.rsi : '-'}`; html += ''; tbody.insertAdjacentHTML('beforeend', html); }); _syncTradeSortButtons(tbodyId, mode); if (rows.some(r => r.isOpen)) { liveQuoteStartFromDom(tbody); } } 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-us_momentum').style.display = tab === 'us_momentum' ? '' : 'none'; $('tab-optuna').style.display = tab === 'optuna' ? '' : 'none'; $('tab-holding').style.display = tab === 'holding' ? '' : 'none'; $('tab-updownbox').style.display = tab === 'updownbox'? '' : 'none'; $('tab-updow').style.display = tab === 'updow' ? '' : 'none'; $('tab-dart').style.display = tab === 'dart' ? '' : '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 === 'us_momentum') { updateUsMomFxHint(); usmomLoadStocks(false); usmomOptSyncDatesFromMain(); if (_usmomOptJobId) usmomStockOptunaStartPolling(); } if (tab === 'dart') dartOnTabShow(); if (tab === 'updownbox') ubxOnTabShow(); if (tab === 'dbband') dbLoadStocks(); if (tab === 'breakout' && typeof boSyncSlModeColors === 'function') boSyncSlModeColors(); if (tab === 'optuna') optunaOnTabShow(); // 보유 라이브 시세: 보유·실거래 탭에서만 SSE/폴링 (다른 탭 이탈 시 중지) if (tab === 'portfolio') { /* pfRenderTable 에서 start */ } else if (tab === 'actual') { const at = $('act_tbody'); if (at && at.querySelector('[data-live-code]')) liveQuoteStartFromDom(at); else liveQuoteStop(); } else { liveQuoteStop(); } }); }); // ════════════════════════════════════════════════════════════════ // DART 수주 공시 // ════════════════════════════════════════════════════════════════ let _dartPollTimer = null; function dartOnTabShow() { dartLoad(); if (_dartPollTimer) clearInterval(_dartPollTimer); _dartPollTimer = setInterval(() => { const t = document.querySelector('[data-tab].active'); if (t && t.dataset.tab === 'dart') dartLoad(true); }, 10000); } async function dartLoad(quiet) { try { const r = await fetch('/api/dart/disclosures?limit=80'); const j = await r.json(); if (!j.ok) throw new Error(j.error || 'fail'); const f = j.flags || {}; if ($('dart_scan_en')) $('dart_scan_en').checked = !!f.scan; if ($('dart_sub_en')) $('dart_sub_en').checked = !!f.subscribe; if ($('dart_trade_en')) $('dart_trade_en').checked = !!f.trade; if ($('dart_strat_en')) $('dart_strat_en').checked = !!f.strategy; if ($('dart_watch_max') && f.watch_max != null) $('dart_watch_max').value = f.watch_max; if ($('dart_watch_ttl') && f.watch_ttl_hours != null) $('dart_watch_ttl').value = f.watch_ttl_hours; if ($('dart_quality_en')) $('dart_quality_en').checked = f.quality_filter !== false; if ($('dart_min_sales') && f.min_sales_pct != null) $('dart_min_sales').value = f.min_sales_pct; if ($('dart_require_theme')) $('dart_require_theme').checked = f.require_theme !== false; const wb = $('dart_watch_tbody'); if (wb) { const w = j.watch || []; wb.innerHTML = w.length ? w.map(x => ` ${x.stock_code||''}${x.corp_name||''} ${(x.report_nm||'').slice(0,40)} ${x.added_at||''}${x.expires_at||''} `).join('') : '워치 없음 (구독 스위치 OFF 또는 공시 대기)'; } const tb = $('dart_disc_tbody'); if (tb) { const rows = j.rows || []; tb.innerHTML = rows.length ? rows.map(x => { const ok = x.filter_ok == null ? null : Number(x.filter_ok) === 1; const badge = ok === true ? '통과' : ok === false ? `${(x.filter_reason||'제외').slice(0,24)}` : '-'; const pct = (x.sales_pct != null && x.sales_pct !== '') ? Number(x.sales_pct).toFixed(1) : '-'; return ` ${x.first_seen_at||''} ${x.stock_code||''}${x.corp_name||''} ${pct} ${badge} ${(x.report_nm||'').trim()} ${x.url ? `원문` : ''} `; }).join('') : '공시 없음'; } } catch (e) { if (!quiet) console.error('dartLoad', e); } } async function dartSaveConfig() { const body = { DART_SCAN_ENABLED: $('dart_scan_en') && $('dart_scan_en').checked ? 'true' : 'false', DART_SUBSCRIBE_ENABLED: $('dart_sub_en') && $('dart_sub_en').checked ? 'true' : 'false', DART_TRADE_ENABLED: $('dart_trade_en') && $('dart_trade_en').checked ? 'true' : 'false', STRATEGY_DART_ENABLED: $('dart_strat_en') && $('dart_strat_en').checked ? 'true' : 'false', DART_WATCH_MAX: $('dart_watch_max') ? String($('dart_watch_max').value || 15) : '15', DART_WATCH_TTL_HOURS: $('dart_watch_ttl') ? String($('dart_watch_ttl').value || 24) : '24', DART_QUALITY_FILTER_ENABLED: $('dart_quality_en') && $('dart_quality_en').checked ? 'true' : 'false', DART_MIN_SALES_PCT: $('dart_min_sales') ? String($('dart_min_sales').value || 5) : '5', DART_REQUIRE_THEME: $('dart_require_theme') && $('dart_require_theme').checked ? 'true' : 'false', }; const r = await fetch('/api/dart/config', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body), }); const j = await r.json(); alert(j.ok ? '저장됨: ' + (j.saved||[]).join(', ') : ('실패: ' + (j.error||''))); dartLoad(); } async function dartRunBacktest() { const s = $('dart_bt_start') && $('dart_bt_start').value; const e = $('dart_bt_end') && $('dart_bt_end').value; if (!s || !e) { alert('시작/종료일 필요'); return; } $('dart_bt_out').textContent = '실행 중…'; try { const r = await fetch(`/api/backtest/dart?start=${encodeURIComponent(s)}&end=${encodeURIComponent(e)}`); const j = await r.json(); if (!j.ok) throw new Error(j.error || 'fail'); $('dart_bt_out').textContent = `이벤트 ${j.events} · 거래 ${j.trade_count} · 승률 ${Number(j.win_rate||0).toFixed(1)}% · PnL ${fmtKrw(j.total_pnl)}\n` + (j.trades||[]).slice(0, 20).map(t => `${t.code} ${t.entry_time}→${t.exit_time} ${t.reason} pnl=${Math.round(t.pnl||0)}` ).join('\n'); } catch (err) { $('dart_bt_out').textContent = '오류: ' + err; } } // ════════════════════════════════════════════════════════════════ // 영구구독 관리 (permanent_subscriptions: KR 국내WS / US 해외WS) // ════════════════════════════════════════════════════════════════ let _permRefreshTimer = null; let _permRefreshSec = 15; function permOnMarket() { // 시장 선택 시 거래소 기본값 자동 보정 (KR→KRX, US→NASD) const mk = $('perm_market') ? $('perm_market').value : 'KR'; if ($('perm_exch')) $('perm_exch').value = (mk === 'US') ? 'NASD' : 'KRX'; } function permFmtQuoteTime(t) { const s = String(t || ''); if (s.length >= 12) { return s.slice(4, 6) + '/' + s.slice(6, 8) + ' ' + s.slice(8, 10) + ':' + s.slice(10, 12); } if (s.length >= 8) return s.slice(0, 4) + '-' + s.slice(4, 6) + '-' + s.slice(6, 8); return s; } function permFmtPriceCell(x, fxRate) { const px = Number(x.price); if (!(px > 0)) { return '
시세대기'; } const isUs = String(x.market_type || '').toUpperCase() === 'US'; const fx = getDisplayFxRate(fxRate != null ? fxRate : x.fx_rate); let main; if (isUs) { const krw = (x.price_krw != null && Number(x.price_krw) > 0) ? Number(x.price_krw) : (fx > 0 ? Math.round(px * fx) : 0); main = '$' + px.toFixed(4) + ''; if (krw > 0) { main += '
(' + Math.round(krw).toLocaleString('ko-KR') + '원' + (fx > 0 ? (' · ' + fx.toFixed(2)) : '') + ')'; } } else { main = '' + Math.round(px).toLocaleString('ko-KR') + ''; } let chgHtml = ''; const chg = x.chg_pct; if (chg != null && chg !== '' && Number.isFinite(Number(chg))) { const v = Number(chg); const cls = v >= 0 ? 'text-pnl-pos' : 'text-pnl-neg'; chgHtml = '
' + (v >= 0 ? '+' : '') + v.toFixed(2) + '%'; } const t = (x.price_src === 'tick' && x.tick_time) ? x.tick_time : (x.candle_time || x.tick_time || ''); const src = (x.price_src === 'tick') ? 'tick' : (x.price_src === 'candle' ? '봉' : ''); const meta = permFmtQuoteTime(t) + (src ? (' ·' + src) : ''); return main + chgHtml + (meta ? ('
' + meta + '') : ''); } function permEnsureAutoRefresh(sec) { const s = Math.max(5, Number(sec) || _permRefreshSec || 15); _permRefreshSec = s; if (_permRefreshTimer) clearInterval(_permRefreshTimer); _permRefreshTimer = setInterval(function () { const tab = $('tab-updow'); if (tab && tab.style.display !== 'none') permLoad({ quiet: true }); }, s * 1000); } async function permLoad(opt) { const quiet = !!(opt && opt.quiet); const tb = $('perm_tbody'); if (!tb) return; try { const url = quiet ? '/api/permanent_subs?light=1' : '/api/permanent_subs'; const r = await fetch(url); const j = await r.json(); const rows = (j && j.rows) || []; const baseTf = Number((j && j.base_tf_min) || 1) || 1; if (j && j.fx_rate) setDisplayFxRate(j.fx_rate); if (j && j.refresh_sec) permEnsureAutoRefresh(j.refresh_sec); const fx = getDisplayFxRate(j && j.fx_rate); 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'; const nm = (x.name || '').trim(); const code = x.code || ''; const title = nm && nm !== code ? ('' + code + '
' + nm + '') : ('' + code + ''); const editPayload = JSON.stringify({ code: x.code, market_type: x.market_type, exchange: x.exchange, symbol: x.symbol, note: x.note || '', }); return '' + '' + title + '' + '' + permFmtPriceCell(x, fx) + '' + '' + mkBadge + '' + '' + (x.exchange || '') + '' + '' + baseTf + '분' + '' + (x.ws_tr_key || '') + '' + '' + onBadge + '' + '' + (x.note || '') + '' + ' ' + '' + ''; }).join(''); } catch (e) { if (!quiet) { 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_note')) $('perm_note').value = x.note || ''; permOnMarket(); } 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() : '', enabled: true, note: $('perm_note') ? $('perm_note').value : '', }; try { const r = await fetch('/api/permanent_subs/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); const j = await r.json(); if (!j.ok) { alert('저장 실패: ' + (j.error || '')); return; } if ($('perm_code')) $('perm_code').value = ''; if ($('perm_symbol')) $('perm_symbol').value = ''; if ($('perm_note')) $('perm_note').value = ''; permLoad(); } catch (e) { alert('저장 오류: ' + e); } } async function permDelete(code) { if (!confirm(code + ' 영구구독을 삭제할까요?')) return; try { const r = await fetch('/api/permanent_subs/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code: code }), }); const j = await r.json(); if (!j.ok && j.error) { alert('삭제 실패: ' + j.error); } permLoad(); } catch (e) { alert('삭제 오류: ' + e); } } // ──────────────────────────────────────────── // 오늘 운영 대시보드 // ──────────────────────────────────────────── function dashFmtKrw(n) { return fmtKrw(n); } function dashFmtPct(n) { const v = Number(n) || 0; return (v >= 0 ? '+' : '') + v.toFixed(2) + '%'; } function dashPnlCls(n) { return Number(n) >= 0 ? 'text-pnl-pos' : 'text-pnl-neg'; } function dashInitDate() { const el = $('dash_date'); if (!el) return; if (!el.value) { el.value = kstTradingDayIso(_krHolidays); } else { el.value = kstClampToPrevTradingDayIso(el.value, _krHolidays); } } 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 = ''; let prevSidOps = ''; (d.strategies || []).forEach(row => { const sid = row.strategy_id; const src = row.source || ''; const isLive = !!row.is_live_source; const onBadge = !src ? (row.enabled ? 'ON' : 'OFF') : (isLive ? (row.enabled ? 'ON' : 'OFF') : ''); const srcCls = src === 'ls' ? 'dash-univ-src-ls' : 'dash-univ-src-kiwoom'; const srcCell = src ? `${row.source_label || src}` + (isLive ? ' 실매' : '') : '—'; const pnlCls = dashPnlCls(row.realized_pnl_krw); const retCls = dashPnlCls(row.return_pct); const trCls = [ prevSidOps && prevSidOps !== sid ? 'dash-ops-group-start' : '', src && !isLive ? 'dash-ops-inactive' : '', ].filter(Boolean).join(' '); prevSidOps = sid; const mute = src && !isLive; const fmtOrDash = (v, fn) => (mute ? '—' : fn(v)); tbody.insertAdjacentHTML('beforeend', ` ${row.label || sid}
${sid} ${srcCell} ${onBadge} ${fmtOrDash(row.buy_turnover_krw, dashFmtKrw)} ${fmtOrDash(row.sell_turnover_krw, dashFmtKrw)} ${fmtOrDash(row.turnover_krw, dashFmtKrw)} ${mute ? '—' : row.closed_trades} ${mute ? '—' : row.open_positions} ${fmtOrDash(row.realized_pnl_krw, dashFmtKrw)} ${fmtOrDash(row.budget_limit_krw, dashFmtKrw)} ${fmtOrDash(row.budget_used_krw, dashFmtKrw)} ${fmtOrDash(row.budget_now_krw || 0, dashFmtKrw)} ${fmtOrDash(row.budget_usage_pct, dashFmtPct)} ${fmtOrDash(row.return_pct, dashFmtPct)} ${isLive || !src ? `` : ''} `); }); $('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.strategy_source || '') + ' · ' + (notes.turnover || '') + ' · ' + (notes.budget_used || '') + ' · ' + (notes.budget_now || '') + ' · ' + (notes.return_pct || ''); // 조건검색 이력: 전략별 키움 / LS 두 줄 const univBody = $('dash_univ_tbody'); if (univBody) { univBody.innerHTML = ''; const univRows = d.universe_history || []; let prevSid = ''; univRows.forEach(row => { const sid = row.strategy_id || ''; const src = row.source || ''; const srcCls = src === 'ls' ? 'dash-univ-src-ls' : 'dash-univ-src-kiwoom'; const liveBadge = row.is_live_source ? ' 실매' : ''; const liveSrc = row.live_universe_source || '—'; const lastAt = row.last_at || '—'; const firstAt = row.first_at || '—'; const stale = !!row.stale; const trCls = [ prevSid && prevSid !== sid ? 'dash-univ-group-start' : '', stale ? 'dash-univ-stale' : '', ].filter(Boolean).join(' '); prevSid = sid; univBody.insertAdjacentHTML('beforeend', ` ${row.label || sid}
${sid} ${row.source_label || src}${liveBadge} ${liveSrc} ${Number(row.slots || 0).toLocaleString()} ${Number(row.codes || 0).toLocaleString()} ${Number(row.rows || 0).toLocaleString()} ${firstAt} ${lastAt}${stale ? ' 장전끊' : ''} `); }); const histOn = d.ls_condition_history_enabled; const meta = $('dash_univ_meta'); if (meta) { meta.textContent = histOn ? 'LS_CONDITION_HISTORY_ENABLED=ON' : 'LS_CONDITION_HISTORY_ENABLED=OFF (LS 적재 확인)'; } const un = $('dash_univ_notes'); if (un) { un.textContent = (notes.universe_history || '') + ' · 키움=target_candidates_history · LS=ls_candidates_history' + (staleNoteFromUniv(univRows) || ''); } } } catch (e) { alert('대시보드 오류: ' + e); } finally { showSpinner(false); } } function staleNoteFromUniv(rows) { const lsStale = (rows || []).filter(r => r.source === 'ls' && r.stale); if (!lsStale.length) return ''; return ' · ⚠ LS 이력이 장시작 전에서 끊김 — 장중 적재 점검 필요'; } 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-us_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(); // ──────────────────────────────────────────── // 보유중 라이브 시세 (ws_ticks SELECT via API — 매매 비접촉) // ──────────────────────────────────────────── let _liveQuoteES = null; let _liveQuotePoll = null; let _liveQuotePrevPx = {}; let _liveQuoteCodesKey = ''; function liveQuoteStop() { if (_liveQuoteES) { try { _liveQuoteES.close(); } catch (e) { /* ignore */ } _liveQuoteES = null; } if (_liveQuotePoll) { clearInterval(_liveQuotePoll); _liveQuotePoll = null; } _liveQuoteCodesKey = ''; } function liveQuoteCollectCodes(root) { const scope = root || document; const codes = []; const seen = new Set(); scope.querySelectorAll('[data-live-code]').forEach(el => { const c = String(el.getAttribute('data-live-code') || '').trim(); if (!c || seen.has(c)) return; seen.add(c); codes.push(c); }); return codes; } function liveQuoteFlash(el, dir) { if (!el) return; el.classList.remove('live-quote-flash-up', 'live-quote-flash-down'); void el.offsetWidth; el.classList.add(dir > 0 ? 'live-quote-flash-up' : 'live-quote-flash-down'); } function liveQuoteFmtPx(px, isUsd) { const n = Number(px); if (!Number.isFinite(n) || n <= 0) return '—'; if (isUsd) return fmtUsdWithKrw(n, getDisplayFxRate()); return fmtWon(n); } function liveQuoteFmtPnl(pnl, isUsd) { if (!Number.isFinite(pnl)) return '—'; if (isUsd) return fmtUsdWithKrw(pnl, getDisplayFxRate()); return fmtWon(pnl); } function liveQuoteFmtPctNum(pct) { if (!Number.isFinite(pct)) return '—'; const sign = pct >= 0 ? '+' : ''; return `${sign}${pct.toFixed(2)}%`; } function liveQuoteApplyPctBadge(el, pct) { if (!el) return; el.classList.remove('badge-win', 'badge-loss', 'badge-flat', 'text-pnl-pos', 'text-pnl-neg'); if (!Number.isFinite(pct)) { if (el.tagName === 'SPAN') el.classList.add('badge-flat'); return; } if (el.tagName === 'SPAN') { el.classList.add(pct > 0 ? 'badge-win' : (pct < 0 ? 'badge-loss' : 'badge-flat')); } else { el.classList.add(pct >= 0 ? 'text-pnl-pos' : 'text-pnl-neg'); } } function liveQuoteApplyPayload(d) { if (!d || !d.ok || d.enabled === false) return; const quotes = d.quotes || {}; document.querySelectorAll('tr[data-live-code]').forEach(tr => { const code = String(tr.getAttribute('data-live-code') || '').trim(); const q = quotes[code]; if (!q || !(Number(q.price) > 0)) return; const px = Number(q.price); const buy = Number(tr.getAttribute('data-live-buy') || 0); const qty = Number(tr.getAttribute('data-live-qty') || 0); const isUsd = String(tr.getAttribute('data-live-usd') || '') === '1'; const prev = _liveQuotePrevPx[code]; const pxEl = tr.querySelector('[data-live-cell="px"]'); const pnlEl = tr.querySelector('[data-live-cell="pnl"]'); const pctEl = tr.querySelector('[data-live-cell="pct"]'); const dayEl = tr.querySelector('[data-live-cell="day"]'); const evalEl = tr.querySelector('[data-live-cell="eval"]'); if (pxEl) { pxEl.innerHTML = liveQuoteFmtPx(px, isUsd); if (prev != null && Number.isFinite(prev) && prev > 0 && px !== prev) { liveQuoteFlash(pxEl, px > prev ? 1 : -1); } pxEl.classList.toggle('live-quote-stale', !!q.stale); } const pnl = (buy > 0 && qty > 0) ? (px - buy) * qty : NaN; const buyPct = buy > 0 ? ((px - buy) / buy) * 100 : NaN; const dayPct = (q.day_chg_pct != null && Number.isFinite(Number(q.day_chg_pct))) ? Number(q.day_chg_pct) : NaN; if (pnlEl && Number.isFinite(pnl)) { const label = tr.classList.contains('trade-row-open') ? `${liveQuoteFmtPnl(pnl, isUsd)} 평가` : liveQuoteFmtPnl(pnl, isUsd); pnlEl.innerHTML = label; pnlEl.classList.toggle('text-pnl-pos', pnl > 0); pnlEl.classList.toggle('text-pnl-neg', pnl < 0); } // 당일 등락(전일종가 대비)=메인, 매수가 대비=보조 if (dayEl) { dayEl.textContent = liveQuoteFmtPctNum(dayPct); liveQuoteApplyPctBadge(dayEl, dayPct); if (prev != null && Number.isFinite(prev) && px !== prev) { liveQuoteFlash(dayEl, px > prev ? 1 : -1); } } if (pctEl) { if (dayEl) { pctEl.innerHTML = Number.isFinite(buyPct) ? `${liveQuoteFmtPctNum(buyPct)}` + `
매수대비
` : '—'; liveQuoteApplyPctBadge(pctEl, buyPct); } else { pctEl.innerHTML = Number.isFinite(dayPct) ? `
${liveQuoteFmtPctNum(dayPct)}
` + `
당일` + (Number.isFinite(buyPct) ? ` · 매수 ${liveQuoteFmtPctNum(buyPct)}` : '') + `
` : (Number.isFinite(buyPct) ? `${liveQuoteFmtPctNum(buyPct)}
매수대비
` : '—'); liveQuoteApplyPctBadge(pctEl, Number.isFinite(dayPct) ? dayPct : buyPct); if (prev != null && Number.isFinite(prev) && px !== prev) { liveQuoteFlash(pctEl, px > prev ? 1 : -1); } } } if (evalEl && qty > 0) { evalEl.innerHTML = liveQuoteFmtPx(px * qty, isUsd); } _liveQuotePrevPx[code] = px; }); } function liveQuoteStartFromDom(root) { const codes = liveQuoteCollectCodes(root); const key = codes.slice().sort().join(','); if (!codes.length) { liveQuoteStop(); return; } if (key === _liveQuoteCodesKey && (_liveQuoteES || _liveQuotePoll)) { return; } liveQuoteStop(); _liveQuoteCodesKey = key; const q = 'codes=' + encodeURIComponent(codes.join(',')); const url = '/api/portfolio/live_quotes_stream?' + q; if (typeof EventSource !== 'undefined') { try { _liveQuoteES = new EventSource(url); _liveQuoteES.onmessage = (ev) => { try { liveQuoteApplyPayload(JSON.parse(ev.data)); } catch (e) { /* ignore */ } }; _liveQuoteES.onerror = () => { if (_liveQuoteES) { try { _liveQuoteES.close(); } catch (e2) { /* ignore */ } _liveQuoteES = null; } if (!_liveQuotePoll) { _liveQuotePoll = setInterval(() => { fetch('/api/portfolio/live_quotes?' + q) .then(r => r.json()) .then(liveQuoteApplyPayload) .catch(() => {}); }, 500); } }; return; } catch (e) { /* fall through */ } } _liveQuotePoll = setInterval(() => { fetch('/api/portfolio/live_quotes?' + q) .then(r => r.json()) .then(liveQuoteApplyPayload) .catch(() => {}); }, 400); } // ──────────────────────────────────────────── // 보유·매도 (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 nameDisplay = (it.name && it.name !== it.code) ? `${it.name}
${it.code}` : `${it.code}`; const sellBtn = it.can_sell ? `` : '매도불가'; const isUs = String(it.strategy || '').toUpperCase() === 'US_MOMENTUM' || (!/^\d{6}$/.test(String(it.code || ''))); const buyRef = Number(it.broker_avg_price > 0 ? it.broker_avg_price : it.buy_price) || 0; const qtyRef = Number(it.broker_qty != null && it.broker_qty > 0 ? it.broker_qty : it.db_qty) || 0; const liveAttrs = (String(it.code || '') && buyRef > 0 && qtyRef > 0) ? ` data-live-code="${String(it.code).replace(/"/g, '')}" data-live-buy="${buyRef}" data-live-qty="${qtyRef}" data-live-usd="${isUs ? '1' : '0'}"` : ''; tbody.insertAdjacentHTML('beforeend', ` ${stratLbl} ${nameDisplay} ${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} `); }); liveQuoteStartFromDom(tbody); } // 보유·매도 정렬 버튼 (다른 탭과 동일 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)); } function setKrHolidays(list) { _krHolidays = new Set((list || []).map(String).filter(Boolean)); } /** iso 하루 전 (캘린더) */ function kstAddDaysIso(iso, deltaDays) { const [y, m, d] = String(iso).split('-').map(x => parseInt(x, 10)); const dt = new Date(Date.UTC(y, m - 1, d)); dt.setUTCDate(dt.getUTCDate() + deltaDays); const yy = dt.getUTCFullYear(); const mm = String(dt.getUTCMonth() + 1).padStart(2, '0'); const dd = String(dt.getUTCDate()).padStart(2, '0'); return `${yy}-${mm}-${dd}`; } function kstWeekdayMon0(iso) { const [y, m, d] = String(iso).split('-').map(x => parseInt(x, 10)); // UTC 정오로 파싱해 요일 드리프트 방지 return new Date(Date.UTC(y, m - 1, d, 12, 0, 0)).getUTCDay(); // 0=일 … 6=토 } function kstIsTradingDayIso(iso, holidays) { if (!iso) return false; const wd = kstWeekdayMon0(iso); if (wd === 0 || wd === 6) return false; const hol = holidays || _krHolidays; return !(hol && hol.has(iso)); } /** 주말·휴장이면 이전 장운영일(YYYY-MM-DD) */ function kstClampToPrevTradingDayIso(iso, holidays, maxBack) { let cur = String(iso || '').slice(0, 10); if (!cur) return cur; const lim = Math.max(1, maxBack || 14); for (let i = 0; i <= lim; i++) { if (kstIsTradingDayIso(cur, holidays)) return cur; cur = kstAddDaysIso(cur, -1); } return cur; } /** 실거래 조회 기준일 — 장 시작 전이면 전날, 이후 주말/휴장 보정 */ function kstTradingDayIso(holidays) { const base = kstHourNow() < 9 ? kstYesterdayIso() : kstTodayParts().iso; return kstClampToPrevTradingDayIso(base, holidays); } function setDateVal(id, val) { const el = $(id); if (el && val) el.value = val; } /** 날짜 인풋: 주말/휴장 선택 시 이전 장운영일로 스냅 */ function bindKrTradingDayDateInputs() { document.querySelectorAll('input[type="date"]').forEach(el => { if (el.dataset.krTradingBound === '1') return; el.dataset.krTradingBound = '1'; el.addEventListener('change', function() { if (!this.value) return; const c = kstClampToPrevTradingDayIso(this.value, _krHolidays); if (c && c !== this.value) this.value = c; }); }); } function initDefaultDates(datesPayload) { const hol = (datesPayload && datesPayload.holidays) ? datesPayload.holidays : [..._krHolidays]; setKrHolidays(hol); const endKst = (datesPayload && datesPayload.end) ? datesPayload.end : kstClampToPrevTradingDayIso(kstTodayParts().iso, _krHolidays); const startKst = (datesPayload && datesPayload.start) ? datesPayload.start : kstClampToPrevTradingDayIso(kstAddDaysIso(endKst, -7), _krHolidays); // 실거래 분석: 단일 거래일 기본 조회 (장전·주말·휴장 → 이전 장운영일) const tradingDayKst = (datesPayload && datesPayload.trading_day) ? datesPayload.trading_day : kstTradingDayIso(_krHolidays); setDateVal('act_start', tradingDayKst); setDateVal('act_end', tradingDayKst); setDateVal('dash_date', tradingDayKst); setDateVal('lc_date', tradingDayKst); // 백테 탭: 시작 ~ 종료 (둘 다 거래일) setDateVal('bt_start', startKst); setDateVal('bt_end', endKst); setDateVal('tl_start', startKst); setDateVal('tl_end', endKst); setDateVal('db_start', startKst); setDateVal('db_end', endKst); setDateVal('db_fetch_start', startKst); setDateVal('bo_start', startKst); setDateVal('bo_end', endKst); setDateVal('rb_start', startKst); setDateVal('rb_end', endKst); setDateVal('mom_start', startKst); setDateVal('mom_end', endKst); setDateVal('usmom_start', startKst); setDateVal('usmom_end', endKst); setDateVal('hd_start', tradingDayKst); setDateVal('hd_end', tradingDayKst); setDateVal('hd_fetch_start', tradingDayKst); setDateVal('ubx_start', startKst); setDateVal('ubx_end', endKst); const lr = $('lastRefresh'); if (lr) lr.textContent = '업데이트: ' + new Date().toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' }); bindKrTradingDayDateInputs(); } (function() { // 주말만이라도 즉시 보정 → /api/env/params 의 dates 로 휴장일 재보정 initDefaultDates(null); 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 => { if (d && d.dates) initDefaultDates(d.dates); 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'); } set('bt_rsi_period', s.rsi_period); set('bt_vol_mult', s.vol_mult); set('bt_time_start', s.time_start_hm); set('bt_time_end', s.time_end_hm); set('bt_max_daily', s.max_daily); if (s.max_stocks != null) set('bt_max_stocks', s.max_stocks); if (s.total_budget_krw != null) set('bt_total_budget', s.total_budget_krw); if ($('bt_skip_hts_dupes')) $('bt_skip_hts_dupes').checked = s.skip_hts_scan_dupes !== false; if ($('bt_require_reversal')) { const rv = s.require_reversal_candle; $('bt_require_reversal').checked = rv !== false && rv !== 0 && rv !== '0' && rv !== 'false'; } if (s.eod_enabled !== undefined && $('bt_eod_enabled')) { const ev = String(s.eod_enabled).trim().toLowerCase(); $('bt_eod_enabled').checked = (ev === '1' || ev === 'true' || ev === 'y' || ev === 'yes' || ev === 'on' || s.eod_enabled === true); } if (s.eod_hm != null) set('bt_eod_hm', s.eod_hm); // 꼬리잡기 탭 — config_short + env_config (실매·파라서치와 동일) fillTailFormFromApi(d.tail || {}); // 더블BB 탭 fillDbBandFormFromApi(d.dbband || {}); // 모멘텀 탭 — config_momentum + env_config fillMomentumFormFromApi(d.momentum || {}); fillUsMomentumFormFromApi(d.us_momentum || d.momentum || {}); // 돌파 탭 — DB(env_config BREAKOUT_*) = 백테 API _bo_defaults_from_db 와 동일 const b = d.breakout || {}; setM('bo_lookback', b.lookback_min); setM('bo_vol_win', b.vol_window); setM('bo_vol_mult', b.vol_mult); setM('bo_min_turnover', b.min_turnover_1m_pct); setM('bo_prev_min', b.prev_chg_min); setM('bo_prev_max', b.prev_chg_max); if (b.entry_mode && $('bo_entry_mode')) { $('bo_entry_mode').value = String(b.entry_mode).toLowerCase(); } setM('bo_intrabar_slip', b.intrabar_slippage_pct); if (b.sl_mode && $('bo_sl_mode')) { $('bo_sl_mode').value = String(b.sl_mode).toLowerCase(); } setM('bo_sl', b.sl_pct); if (b.atr_period != null) setM('bo_atr_period', b.atr_period); if (b.atr_sl_mult != null) setM('bo_atr_sl_mult', b.atr_sl_mult); if (b.atr_sl_min_pct != null) setM('bo_atr_sl_min', b.atr_sl_min_pct); if (b.atr_sl_max_pct != null) setM('bo_atr_sl_max', b.atr_sl_max_pct); boSyncSlModeColors(); setM('bo_tp', b.tp_pct); setM('bo_trail', b.trail_pct); if (b.trail_arm_pct != null) setM('bo_trail_arm', b.trail_arm_pct); setM('bo_shoulder_smin', b.shoulder_min_high_pct); setM('bo_shoulder_scut', b.shoulder_cut_pct); setM('bo_time_start', b.time_start_hm); setM('bo_time_end', b.time_end_hm); if (b.eod_enabled !== undefined && $('bo_eod_enabled')) { $('bo_eod_enabled').checked = !!b.eod_enabled; } if (b.eod_hm != null) setM('bo_eod_hm', b.eod_hm); if (b.max_hold_bars != null) setM('bo_max_hold', b.max_hold_bars); setM('bo_max_daily', b.max_daily); setM('bo_cooldown', b.cooldown_min); setM('bo_max_chg', b.max_daily_chg); setM('bo_min_price', b.min_price); if (b.confirm_margin_pct != null) setM('bo_confirm_margin', b.confirm_margin_pct); if (b.body_min_pct != null) setM('bo_body_min', b.body_min_pct); if ($('bo_ratchet')) $('bo_ratchet').value = (b.ratchet_tiers != null ? String(b.ratchet_tiers) : ''); 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 ($('bo_skip_hts_dupes')) $('bo_skip_hts_dupes').checked = !!b.skip_hts_scan_dupes; 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 (b.daily_profit_enabled !== undefined && $('bo_daily_profit_enabled')) { $('bo_daily_profit_enabled').checked = !!b.daily_profit_enabled; } if (b.daily_risk_budget_enabled !== undefined && $('bo_daily_risk_budget_enabled')) { $('bo_daily_risk_budget_enabled').checked = !!b.daily_risk_budget_enabled; } 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); // 날짜(거래일) 확정 후 실거래 분석 1회 — 빈 날짜로 전체 조회·고스트 노출 방지 loadActual(); }) .catch(() => { /* DB 연결 실패 시 HTML 기본값 유지 — 로컬 거래일로라도 조회 */ loadActual(); }); 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); const isUs = String(d.currency || p.market || '').toUpperCase() === 'USD' || String(document.querySelector('input[name=act_strategy]:checked')?.value || '').toUpperCase() === 'US_MOMENTUM'; const fxEnd = Number(s.fx_rate_end || 0) || getDisplayFxRate(); if (fxEnd > 0) setDisplayFxRate(fxEnd); $('a_total').textContent = fmt(s.total_trades) + '건'; $('a_winrate').textContent = s.win_rate + '%'; colorPnl($('a_winrate'), s.win_rate - 50); if (isUs) { if (s.total_pnl_krw != null && Number.isFinite(Number(s.total_pnl_krw))) { $('a_pnl').innerHTML = `${fmtUsd(s.total_pnl)} (${fmtWon(s.total_pnl_krw)}원)`; } else { $('a_pnl').innerHTML = fmtUsdWithKrw(s.total_pnl, fxEnd); } colorPnl($('a_pnl'), s.total_pnl); if (s.max_drawdown_krw != null && Number.isFinite(Number(s.max_drawdown_krw))) { $('a_mdd').innerHTML = `-${fmtUsd(s.max_drawdown)} (${fmtWon(s.max_drawdown_krw)}원)`; } else { $('a_mdd').textContent = '-' + fmtUsdWithKrwText(s.max_drawdown, fxEnd); } } else { $('a_pnl').textContent = fmtKrw(s.total_pnl); colorPnl($('a_pnl'), s.total_pnl); $('a_mdd').textContent = '-' + fmtWon(s.max_drawdown) + '원'; } $('a_pf').textContent = s.profit_factor >= 999 ? '∞' : s.profit_factor; colorPnl($('a_pf'), s.profit_factor - 1); $('a_hold').textContent = s.avg_hold_min + '분'; // 운용한도 대비 수익률 — API summary에 없으면 params.total_budget_krw 로 계산 const aSign = (v) => (v > 0 ? '+' : ''); let botPct = s.bot_pct; let dailyAvg = s.daily_avg_pct; const tb = Number(p.total_budget_krw || 0); if ((botPct == null || botPct === '') && tb > 0) { botPct = Math.round((Number(s.total_pnl || 0) / tb) * 10000) / 100; } if ((dailyAvg == null || dailyAvg === '') && botPct != null) { const startEl = $('act_start'); const endEl = $('act_end'); let days = 1; try { if (startEl && endEl && startEl.value && endEl.value) { const t0 = new Date(startEl.value + 'T00:00:00'); const t1 = new Date(endEl.value + 'T00:00:00'); days = Math.max(1, Math.round((t1 - t0) / 86400000) + 1); } } catch (e) { days = 1; } dailyAvg = Math.round((Number(botPct) / days) * 1000) / 1000; } if ($('a_bot_pct')) { $('a_bot_pct').textContent = (botPct == null) ? '-' : (aSign(botPct) + botPct + '%'); if (botPct != null) colorPnl($('a_bot_pct'), botPct); } if ($('a_daily_avg_pct')) { $('a_daily_avg_pct').textContent = (dailyAvg == null) ? '-' : (aSign(dailyAvg) + dailyAvg + '%'); if (dailyAvg != null) colorPnl($('a_daily_avg_pct'), dailyAvg); } 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) { fillTradePnLContext(actCtx, { label: '실매 trade_history', totalBudget: Number(p.total_budget_krw || 0), peakCum: Number(s.peak_cum_pnl || 0), peakAt: s.peak_cum_at || '', totalPnl: s.total_pnl, }); } 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( `💡 장중 누적이 +${fmtKrw(s.peak_cum_pnl)}까지 갔다가 이후 청산으로 ${fmtKrw(s.total_pnl)} — ` + `누적손익 열은 매도 완료 순 합계입니다.`, ); } const tickLive = d.tick_live || {}; if (tickLive.tick_bar_coverage_pct != null || tickLive.tick_bar_coverage_pct_traded != null) { const rows = tickLive.ws_tick_rows_loaded != null ? Number(tickLive.ws_tick_rows_loaded).toLocaleString() : '—'; parts.push( `📊 실매 틱DB | 틱 ${rows}건 · 분봉커버 ${fmtTickCoverageLabel(tickLive)}`, ); } if (parts.length) { note.style.display = ''; note.innerHTML = parts.join('
'); } else { note.style.display = 'none'; note.innerHTML = ''; } } if (filtered > 0) { console.log(`[실거래 분석] ghost_purge·강제정리 ${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), currency: isUs ? 'USD' : '', fxRates: d.fx_rates || {}, fxRate: fxEnd, }); } // ──────────────────────────────────────────── // 돌파 백테스트 실행 // ──────────────────────────────────────────── 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', ratchet_tiers: ($('bo_ratchet') && $('bo_ratchet').value.trim()) || '', 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', universe_history_source: univHistorySourceParam('bo_univ_history_source'), candle_source: $('bo_candle_source')?.value || '', tick_source: $('bo_tick_source')?.value || '', ob_source: $('bo_ob_source')?.value || '', 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', daily_profit_enabled: $('bo_daily_profit_enabled')?.checked ? 1 : 0, daily_risk_budget_enabled: $('bo_daily_risk_budget_enabled')?.checked ? 1 : 0, eod_enabled: $('bo_eod_enabled')?.checked ? 1 : 0, eod_hm: ($('bo_eod_hm') && $('bo_eod_hm').value.trim()) || '15:15', skip_hts_scan_dupes: $('bo_skip_hts_dupes')?.checked ? 1 : 0, env_timeline: envTimelineParam('bo_env_timeline'), }; const qs = new URLSearchParams(params).toString(); showSpinner(true); fetch('/api/backtest/breakout?' + qs) .then(async r => { const d = await r.json().catch(() => ({})); if (!r.ok) throw new Error(d.error || ('HTTP ' + r.status)); return d; }) .then(d => { showSpinner(false); renderBreakoutBacktest(d); }) .catch(err => { showSpinner(false); alert('백테스트 오류: ' + err); }); } function saveBreakoutConfig() { const body = { lookback_min: parseInt($('bo_lookback').value, 10), vol_window: parseInt($('bo_vol_win').value, 10), vol_mult: parseFloat($('bo_vol_mult').value), min_turnover_1m_pct: parseFloat($('bo_min_turnover')?.value || '0.05'), prev_chg_min: parseFloat($('bo_prev_min').value), prev_chg_max: parseFloat($('bo_prev_max').value), entry_mode: $('bo_entry_mode')?.value || 'intrabar', intrabar_slippage_pct: parseFloat($('bo_intrabar_slip')?.value || '0'), sl_mode: ($('bo_sl_mode') && $('bo_sl_mode').value) || 'fixed', sl_pct: parseFloat($('bo_sl').value), atr_period: parseInt($('bo_atr_period')?.value || '14', 10), atr_sl_mult: parseFloat($('bo_atr_sl_mult')?.value || '2.0'), atr_sl_min_pct: parseFloat($('bo_atr_sl_min')?.value || '0.8'), atr_sl_max_pct: parseFloat($('bo_atr_sl_max')?.value || '6.0'), tp_pct: parseFloat($('bo_tp').value), trail_pct: parseFloat($('bo_trail').value), trail_arm_pct: parseFloat($('bo_trail_arm')?.value || '0'), shoulder_min_high_pct: parseFloat($('bo_shoulder_smin').value), shoulder_cut_pct: parseFloat($('bo_shoulder_scut').value), time_start_hm: parseInt($('bo_time_start').value, 10), time_end_hm: parseInt($('bo_time_end').value, 10), eod_enabled: !!($('bo_eod_enabled')?.checked), eod_hm: ($('bo_eod_hm') && $('bo_eod_hm').value.trim()) || '15:15', max_hold_bars: parseInt($('bo_max_hold')?.value || '0', 10), max_daily: parseInt($('bo_max_daily').value, 10), cooldown_min: parseFloat($('bo_cooldown').value), max_daily_chg: parseFloat($('bo_max_chg').value), min_price: parseFloat($('bo_min_price').value), confirm_margin_pct: parseFloat($('bo_confirm_margin')?.value || '0'), body_min_pct: parseFloat($('bo_body_min')?.value || '0'), ratchet_tiers: ($('bo_ratchet') && $('bo_ratchet').value.trim()) || '', 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', daily_profit_enabled: !!($('bo_daily_profit_enabled')?.checked), daily_risk_budget_enabled: !!($('bo_daily_risk_budget_enabled')?.checked), skip_hts_scan_dupes: !!($('bo_skip_hts_dupes')?.checked), }; if (!confirm('💾 돌파 봇(BREAKOUT_* env)에 현재 탭 값을 저장할까요?\n실행 중이면 다음 루프부터 반영됩니다.\n다단트레일: ' + (body.daily_profit_enabled ? 'ON' : 'OFF') + ' · 리스크버짓(B): ' + (body.daily_risk_budget_enabled ? 'ON' : 'OFF'))) 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 covLabel = fmtTickCoverageLabel(tickMeta); 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}건 · 분봉커버 ${covLabel}
`; } 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) { fillTradePnLContext(boCtx, { label: '백테 BREAKOUT', totalBudget: Number(p.total_budget_krw || 0), peakCum: Number(s.peak_cum_pnl || 0), peakAt: s.peak_cum_at || '', totalPnl: s.total_pnl, }); } const setTxt = (id, txt) => { const el = $(id); if (el) el.textContent = txt; }; setTxt('bo_total', (s.total_trades||0) + '건'); setTxt('bo_winrate', (s.win_rate||0) + '%'); colorPnl($('bo_winrate'), (s.win_rate||0) - 50); setTxt('bo_pnl', fmtKrw(s.total_pnl)); colorPnl($('bo_pnl'), s.total_pnl); setTxt('bo_pf', (s.profit_factor||0) >= 999 ? '∞' : String(s.profit_factor||0)); colorPnl($('bo_pf'), (s.profit_factor||0) - 1); setTxt('bo_mdd', '-' + fmtWon(s.max_drawdown) + '원'); setTxt('bo_hold', (s.avg_hold_min||0) + '분'); const boSign = v => (v > 0 ? '+' : ''); setTxt('bo_bot_pct', boSign(s.bot_pct || 0) + (s.bot_pct || 0) + '%'); colorPnl($('bo_bot_pct'), s.bot_pct || 0); setTxt('bo_daily_avg_pct', boSign(s.daily_avg_pct || 0) + (s.daily_avg_pct || 0) + '%'); colorPnl($('bo_daily_avg_pct'), s.daily_avg_pct || 0); const winbar = $('bo_winbar_card'); if ((s.total_trades||0) > 0) { if (winbar) winbar.style.display = 'block'; const wr = s.win_rate||0; setTxt('bo_win_label', `🟢 승 ${s.win_trades||0}건 (${wr}%)`); setTxt('bo_loss_label', `🔴 패 ${s.loss_trades||0}건 (${(100-wr).toFixed(1)}%)`); const rg = $('bo_ratio_g'); if (rg) rg.style.width = wr + '%'; const rr = $('bo_ratio_r'); if (rr) rr.style.width = (100-wr) + '%'; } else if (winbar) { winbar.style.display = 'none'; } try { if (d.equity && d.equity.length) { lineChart('bo_equity_chart', d.equity.map(e => e.date), d.equity.map(e => e.cum_pnl), '가상누적손익', '#3fb950'); } else { destroyChart('bo_equity_chart'); } const rKeys = Object.keys(d.reasons || {}); if (rKeys.length) { doughnutChart('bo_reason_chart', rKeys, rKeys.map(k => d.reasons[k])); } else { destroyChart('bo_reason_chart'); } if (d.daily && d.daily.length) { barChart('bo_daily_chart', d.daily.map(e => e.date.slice(5)), d.daily.map(e => e.pnl)); } else { destroyChart('bo_daily_chart'); } } catch (chartErr) { console.error('bo chart render', chartErr); } renderVirtualTrades('bo_tbody', d.trades || [], { showDebug: true, showCumulative: true, totalBudget: Number(p.total_budget_krw || 0), }); 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', env_timeline: envTimelineParam('rb_env_timeline'), }; showSpinner(true); fetch('/api/backtest/range_break?' + new URLSearchParams(params).toString()) .then(async r => { const d = await r.json().catch(() => ({})); if (!r.ok) throw new Error(d.error || ('HTTP ' + r.status)); return d; }) .then(d => { showSpinner(false); renderRangeBreakBacktest(d); }) .catch(err => { showSpinner(false); alert('백테스트 오류: ' + err); }); } function saveRangeBreakConfig() { const body = { box_lookback_min: parseInt($('rb_box_lb').value, 10), box_max_width_pct: parseFloat($('rb_box_max_w').value), setup_vol_max_mult: parseFloat($('rb_setup_vol').value), setup_bear_bars_min: parseInt($('rb_bear_min').value, 10), vol_window: parseInt($('rb_vol_win').value, 10), vol_mult: parseFloat($('rb_vol_mult').value), sl_pct: parseFloat($('rb_sl').value), tp_pct: parseFloat($('rb_tp').value), trail_pct: parseFloat($('rb_trail').value), trail_arm_pct: parseFloat($('rb_trail_arm')?.value || '0'), shoulder_min_high_pct: parseFloat($('rb_shoulder_smin').value), shoulder_cut_pct: parseFloat($('rb_shoulder_scut').value), time_start_hm: parseInt($('rb_time_start').value, 10), time_end_hm: parseInt($('rb_time_end').value, 10), max_daily: parseInt($('rb_max_daily').value, 10), cooldown_min: parseFloat($('rb_cooldown').value), max_daily_chg: parseFloat($('rb_max_chg').value), min_price: parseFloat($('rb_min_price').value), max_loss_krw: parseInt($('rb_max_loss').value, 10), use_high_chase_filter: !!($('rb_use_high_chase')?.checked), slot_money: parseInt($('rb_slot').value, 10), max_stocks: parseInt($('rb_max_stocks')?.value || '20', 10), total_budget_krw: parseInt($('rb_total_budget')?.value || '0', 10), }; if (!confirm('💾 박스권돌파 봇(RANGE_BREAK_* env)에 저장할까요?')) return; fetch('/api/backtest/range_break/save_config', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body), }).then(r => r.json()).then(d => { if (d.error) { alert('❌ ' + d.error); return; } alert('✅ 저장 완료 (env_id: ' + d.env_id + ')'); }).catch(e => alert('오류: ' + e)); } function renderRangeBreakBacktest(d) { if (!d || d.error) { alert('❌ ' + (d && d.error ? d.error : '응답 없음')); return; } if (!d.summary) { alert('summary 없음'); return; } const s = d.summary; const p = d.params || {}; const ra = $('rb_result_area'); if (ra) ra.style.display = 'block'; const pb = $('rb_params_bar'); if (pb) { pb.style.display = 'block'; pb.innerHTML = `박스 ${p.box_lookback_min}분 · 폭≤${p.box_max_width_pct}% · vol×${p.vol_mult} ` + `| ${p.time_window} | 손절-${p.sl_pct}% 익절+${p.tp_pct}% | 종목 ${p.codes_analyzed || 0}개`; } const setTxt = (id, txt) => { const el = $(id); if (el) el.textContent = txt; }; setTxt('rb_total', (s.total_trades || 0) + '건'); setTxt('rb_winrate', (s.win_rate || 0) + '%'); colorPnl($('rb_winrate'), (s.win_rate || 0) - 50); setTxt('rb_pnl', fmtKrw(s.total_pnl)); colorPnl($('rb_pnl'), s.total_pnl); setTxt('rb_pf', (s.profit_factor || 0) >= 999 ? '∞' : String(s.profit_factor || 0)); colorPnl($('rb_pf'), (s.profit_factor || 0) - 1); setTxt('rb_mdd', '-' + fmtWon(s.max_drawdown) + '원'); setTxt('rb_hold', (s.avg_hold_min || 0) + '분'); const rbSign = (v) => (v > 0 ? '+' : ''); setTxt('rb_bot_pct', rbSign(s.bot_pct || 0) + (s.bot_pct || 0) + '%'); colorPnl($('rb_bot_pct'), s.bot_pct || 0); setTxt('rb_daily_avg_pct', rbSign(s.daily_avg_pct || 0) + (s.daily_avg_pct || 0) + '%'); colorPnl($('rb_daily_avg_pct'), s.daily_avg_pct || 0); if ((s.total_trades || 0) > 0 && $('rb_winbar_card')) { $('rb_winbar_card').style.display = ''; const wr = s.win_rate || 0; setTxt('rb_win_label', `🟢 승 ${s.win_trades || 0}건 (${wr}%)`); setTxt('rb_loss_label', `🔴 패 ${s.loss_trades || 0}건 (${(100 - wr).toFixed(1)}%)`); if ($('rb_ratio_g')) $('rb_ratio_g').style.width = wr + '%'; if ($('rb_ratio_r')) $('rb_ratio_r').style.width = (100 - wr) + '%'; } 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 || [], { showCumulative: true, totalBudget: Number(p.total_budget_krw || s.total_budget_krw || 0), }); let rbCtx = $('rb_trade_context'); if (!rbCtx) { const bar = document.querySelector('.trade-sort-bar[data-tbody="rb_tbody"]'); if (bar) { rbCtx = document.createElement('div'); rbCtx.id = 'rb_trade_context'; rbCtx.className = 'mb-2 p-2 rounded'; rbCtx.style.cssText = 'font-size:12px;background:var(--bg-secondary, #1c2128);color:var(--muted);display:none'; bar.parentNode.insertBefore(rbCtx, bar); } } if (rbCtx) { fillTradePnLContext(rbCtx, { label: '백테 RANGE_BREAK', totalBudget: Number(p.total_budget_krw || s.total_budget_krw || 0), peakCum: Number(s.peak_cum_pnl || 0), peakAt: s.peak_cum_at || '', totalPnl: s.total_pnl, }); } 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'), e_min_chg_pct: v('mom_e_min_chg') || '0.2', 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', universe_history_source: univHistorySourceParam('mom_univ_history_source'), candle_source: $('mom_candle_source')?.value || '', tick_source: $('mom_tick_source')?.value || '', ob_source: $('mom_ob_source')?.value || '', 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', daily_profit_enabled: $('mom_daily_profit_enabled')?.checked ? 1 : 0, daily_risk_budget_enabled: $('mom_daily_risk_budget_enabled')?.checked ? 1 : 0, eod_enabled: $('mom_eod_enabled')?.checked ? 1 : 0, eod_hm: ($('mom_eod_hm') && $('mom_eod_hm').value.trim()) || '15:25', backtest_skip_pre_subscribe: $('mom_skip_pre_sub')?.checked ? 1 : 0, env_timeline: envTimelineParam('mom_env_timeline'), }; 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); } } /** 해외 모멘텀 백테 — /api/backtest/us_momentum (국내 momentum API 분기 사용 안 함) */ function runUsMomentumBacktest() { try { showSpinner(true); let start = $('usmom_start')?.value || ''; let end = $('usmom_end')?.value || ''; if (!start) { start = kstMonthStartIso(); setDateVal('usmom_start', start); } if (!end) { end = kstTodayParts().iso; setDateVal('usmom_end', end); } if (end && start && end < start) { showSpinner(false); alert('종료일이 시작일보다 앞섭니다.'); return; } const v = id => { const el = $(id); return el ? el.value : ''; }; const params = { start, end, sl_pct: v('usmom_sl'), tp_pct: v('usmom_tp'), tp_max_pct: v('usmom_tp_max'), slot_money: v('usmom_slot_money'), slots: v('usmom_slots'), total_budget_krw: v('usmom_total_budget') || '0', cooldown_min: v('usmom_cooldown'), shoulder_min_high: v('usmom_smin'), shoulder_cut_pct: v('usmom_scut'), trail_pct: v('usmom_trail') || '0', trail_arm_pct: v('usmom_trail_arm') || '0', max_hold_bars: v('usmom_max_hold') || '0', ratchet_tiers: ($('usmom_ratchet')?.value || '').trim(), time_start: v('usmom_time_start') || '2230', time_end: v('usmom_time_end') || '500', max_daily: v('usmom_max_daily') || '5', high_chase_thr: v('usmom_high_chase') || '0.96', max_daily_chg: v('usmom_max_daily_chg') || '20', min_price: v('usmom_min_price') || '1', max_loss_krw: v('usmom_max_loss') || '0', min_margin: v('usmom_min_margin') || '0.2', use_defense_filters: $('usmom_use_defense')?.checked ? 1 : 0, use_high_chase_filter: $('usmom_use_high_chase_f')?.checked ? 1 : 0, use_daily_range_filter: $('usmom_use_daily_range')?.checked ? 1 : 0, use_ema_filter: $('usmom_use_ema_f')?.checked ? 1 : 0, use_rsi_max_filter: $('usmom_use_rsi_max_f')?.checked ? 1 : 0, pattern_breakout: $('usmom_pat_breakout')?.checked ? 1 : 0, pattern_pullback: $('usmom_pat_pullback')?.checked ? 1 : 0, chase_lookback_min: v('usmom_chase_lookback') || '10', pullback_lookback_min: v('usmom_pullback_lookback') || '15', pullback_min_pct: v('usmom_pullback_min') || '0.3', pullback_max_pct: v('usmom_pullback_max') || '3.0', setup_vol_max_mult: v('usmom_setup_vol') || '0.8', setup_bear_bars_min: v('usmom_setup_bear') || '1', ema_fast_period: v('usmom_ema_fast') || '9', ema_slow_period: v('usmom_ema_slow') || '21', mom_rsi_min: v('usmom_rsi_min'), mom_rsi_max: v('usmom_rsi_max'), mom_vol_mult: v('usmom_vol_mult'), mom_vol_win: v('usmom_vol_win') || '5', mom_time_end: v('usmom_time_end') || '500', mom_max_from_open_pct: v('usmom_max_from_open') || '999', mom_min_from_open_pct: v('usmom_min_from_open') || '-999', eod_enabled: $('usmom_eod_enabled')?.checked ? 1 : 0, eod_hm: ($('usmom_eod_hm') && $('usmom_eod_hm').value.trim()) || '05:00', backtest_skip_pre_subscribe: 0, daily_profit_enabled: $('usmom_daily_profit_enabled')?.checked ? 1 : 0, daily_risk_budget_enabled: $('usmom_daily_risk_budget_enabled')?.checked ? 1 : 0, daily_trail_tiers: ($('usmom_daily_trail_tiers')?.value || '').trim(), daily_profit_mode: ($('usmom_daily_profit_mode')?.value || 'trailing'), codes: ($('usmom_bt_codes')?.value || '').trim(), }; const qs = new URLSearchParams(params).toString(); fetch('/api/backtest/us_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); renderUsMomentumBacktest(d); }) .catch(err => { showSpinner(false); alert('해외 모멘텀 백테 오류: ' + err); }); } catch (err) { showSpinner(false); alert('해외 모멘텀 실행 오류: ' + err); } } function renderUsMomentumBacktest(d) { if (!d || d.error) { alert('❌ ' + (d && d.error ? d.error : '백테스트 응답 없음')); return; } const s = d.summary || {}; const trades = d.trades || []; const p = d.params || {}; const fxRates = d.fx_rates || {}; const fxEnd = Number(s.fx_rate_end || 0) || getDisplayFxRate(); if (fxEnd > 0) setDisplayFxRate(fxEnd); const ra = $('usmom_result_area'); if (ra) ra.style.display = 'block'; const pb = $('usmom_params_bar'); if (pb) { pb.style.display = 'block'; const sess = p.time_window || `${p.time_start_hm ?? '?'}~${p.mom_time_end_hm ?? p.mom_time_end ?? p.time_end_hm ?? '?'}`; const slot = Number(p.slot_money || 0); const bud = Number(p.total_budget_krw || 0); const slotTxt = fxEnd > 0 ? fmtUsdWithKrwText(slot, fxEnd) : fmtUsd(slot); const budTxt = fxEnd > 0 ? fmtUsdWithKrwText(bud, fxEnd) : fmtUsd(bud); const fxNote = fxEnd > 0 ? ` · FX ${fxEnd.toFixed(2)}원/$` : ''; pb.innerHTML = `market=${p.market || 'US'} | 유니버스=${p.universe_source || 'permanent_us'} | ` + `종목 ${p.codes_analyzed || '—'} · 1회 ${slotTxt} · 한도 ${budTxt} · ` + `세션 ${sess}` + (p.max_hold_bars != null ? ` · max_hold=${p.max_hold_bars}` : '') + (p.start && p.end ? ` · ${p.start}~${p.end}` : '') + fxNote; } const ctx = $('usmom_trade_context'); if (ctx) { fillTradePnLContext(ctx, { label: '백테 US_MOMENTUM', totalBudget: Number(p.total_budget_krw || 0), peakCum: Number(s.peak_cum_pnl || 0), peakAt: s.peak_cum_at || '', totalPnl: s.total_pnl, currency: 'USD', fxRate: fxEnd, }); } const setTxt = (id, txt) => { const el = $(id); if (el) el.textContent = txt; }; const setHtml = (id, html) => { const el = $(id); if (el) el.innerHTML = html; }; setTxt('usmom_total', (s.total_trades || 0) + '건'); setTxt('usmom_winrate', (s.win_rate || 0) + '%'); colorPnl($('usmom_winrate'), (s.win_rate || 0) - 50); if (s.total_pnl_krw != null && Number.isFinite(Number(s.total_pnl_krw))) { setHtml('usmom_pnl', `${fmtUsd(s.total_pnl)} (${fmtWon(s.total_pnl_krw)}원)`); } else { setHtml('usmom_pnl', fmtUsdWithKrw(s.total_pnl, fxEnd)); } colorPnl($('usmom_pnl'), s.total_pnl); setTxt('usmom_pf', (s.profit_factor || 0) >= 999 ? '∞' : String(s.profit_factor || 0)); colorPnl($('usmom_pf'), (s.profit_factor || 0) - 1); if (s.max_drawdown_krw != null && Number.isFinite(Number(s.max_drawdown_krw))) { setHtml('usmom_mdd', `-${fmtUsd(s.max_drawdown)} (${fmtWon(s.max_drawdown_krw)}원)`); } else { setTxt('usmom_mdd', '-' + fmtUsdWithKrwText(s.max_drawdown, fxEnd)); } setTxt('usmom_hold', (s.avg_hold_min || 0) + '분'); const usSign = v => (v > 0 ? '+' : ''); setTxt('usmom_bot_pct', usSign(s.bot_pct || 0) + (s.bot_pct || 0) + '%'); colorPnl($('usmom_bot_pct'), s.bot_pct || 0); setTxt('usmom_daily_avg_pct', usSign(s.daily_avg_pct || 0) + (s.daily_avg_pct || 0) + '%'); colorPnl($('usmom_daily_avg_pct'), s.daily_avg_pct || 0); const winbar = $('usmom_winbar_card'); if ((s.total_trades || 0) > 0) { if (winbar) winbar.style.display = 'block'; const wr = s.win_rate || 0; setTxt('usmom_win_label', `🟢 승 ${s.win_trades || 0}건 (${wr}%)`); setTxt('usmom_loss_label', `🔴 패 ${s.loss_trades || 0}건 (${(100 - wr).toFixed(1)}%)`); const rg = $('usmom_ratio_g'); if (rg) rg.style.width = wr + '%'; const rr = $('usmom_ratio_r'); if (rr) rr.style.width = (100 - wr) + '%'; } else if (winbar) { winbar.style.display = 'none'; } try { if (d.equity && d.equity.length) { lineChart('usmom_equity_chart', d.equity.map(e => e.date), d.equity.map(e => e.cum_pnl), '가상누적손익($)', '#3fb950'); } else { destroyChart('usmom_equity_chart'); } const rKeys = Object.keys(d.reasons || {}); if (rKeys.length) { doughnutChart('usmom_reason_chart', rKeys, rKeys.map(k => d.reasons[k])); } else { destroyChart('usmom_reason_chart'); } if (d.daily && d.daily.length) { barChart('usmom_daily_chart', d.daily.map(e => e.date.slice(5)), d.daily.map(e => e.pnl)); } else { destroyChart('usmom_daily_chart'); } } catch (chartErr) { console.error('usmom chart render', chartErr); } renderVirtualTrades('usmom_tbody', trades || [], { showDebug: true, showCumulative: true, totalBudget: Number(p.total_budget_krw || 0), currency: 'USD', fxRates, fxRate: fxEnd, }); if (ra) ra.scrollIntoView({ behavior: 'smooth', block: 'start' }); } function usmomGoOptuna() { /* Optuna 탭 = 전역 US_MOMENTUM_* 만 (종목 cfg는 이 탭 종목 Optuna) */ document.querySelectorAll('.opt-strat-cb').forEach(cb => { cb.checked = (cb.value === 'us_momentum'); }); const el = document.querySelector('[data-tab="optuna"]'); if (el) el.click(); } /** 해외 모멘텀 → 실거래 분석 탭 (국내 전략 탭과 동일: 전략탭에 실거래 UI 없음) */ function usmomActGoActualTab() { const radio = document.querySelector('input[name=act_strategy][value=US_MOMENTUM]'); if (radio) radio.checked = true; const day = ($('act_start')?.value || '').trim() || kstTradingDayIso(_krHolidays); setDateVal('act_start', day); setDateVal('act_end', day); const el = document.querySelector('[data-tab="actual"]'); if (el) el.click(); loadActual(); } /** @deprecated 임베드 실거래/페이퍼 UI 제거 — 실거래 분석 탭으로 */ function loadUsMomActual() { usmomActGoActualTab(); } function loadUsMomPaper() { usmomActGoActualTab(); } let _usmomStockCache = []; function usmomLoadStocks(seed) { const q = seed ? '?seed=1' : ''; fetch('/api/us_momentum/stocks' + q) .then(r => r.json()) .then(d => { _usmomStockCache = d.stocks || []; const sel = $('usmom_stock_code'); const tb = $('usmom_stock_tbody'); if (sel) { const cur = sel.value; sel.innerHTML = _usmomStockCache.map(s => `` ).join('') || ''; if (cur && [...sel.options].some(o => o.value === cur)) sel.value = cur; } if (tb) { if (!_usmomStockCache.length) { tb.innerHTML = '영구구독 US 없음'; } else { tb.innerHTML = _usmomStockCache.map(s => ` ${s.code}${s.stock_group || ''}${s.exchange || ''} ${s.has_stock_cfg ? '✅' : '—'} ${s.enabled ? 'ON' : 'OFF'}` ).join(''); } } if (sel && sel.value) usmomOnStockSelect(); }) .catch(e => { const tb = $('usmom_stock_tbody'); if (tb) tb.innerHTML = `${e}`; }); } function usmomPickStock(code) { const sel = $('usmom_stock_code'); if (sel) sel.value = code; usmomOnStockSelect(); } function usmomOnStockSelect() { const code = ($('usmom_stock_code')?.value || '').trim().toUpperCase(); if (!code) return; const meta = (_usmomStockCache || []).find(s => s.code === code) || {}; if ($('usmom_stock_group')) $('usmom_stock_group').value = meta.stock_group || 'STOCK'; if ($('usmom_stock_exch')) $('usmom_stock_exch').value = meta.exchange || 'NASD'; const badge = $('usmom_stock_cfg_badge'); fetch('/api/us_momentum/stock_cfg?code=' + encodeURIComponent(code)) .then(r => r.json()) .then(d => { if (badge) { badge.textContent = d.has_stock_cfg ? `핀 있음 · ${code}` : `전역 폴백 · ${code}`; badge.style.color = d.has_stock_cfg ? 'var(--accent,#58a6ff)' : 'var(--muted)'; } const c = d.cfg || {}; const setN = (id, v) => { const el = $(id); if (el && v != null && v !== '') el.value = v; }; setN('usmom_sl', c.sl_pct); setN('usmom_tp', c.tp_pct); setN('usmom_tp_max', c.tp_max_pct); setN('usmom_smin', c.shoulder_min_high_pct); setN('usmom_scut', c.shoulder_cut_pct); setN('usmom_trail', c.trail_pct); setN('usmom_trail_arm', c.trail_arm_pct); setN('usmom_max_hold', c.max_hold_bars); if (c.ratchet_tiers != null && $('usmom_ratchet')) $('usmom_ratchet').value = c.ratchet_tiers; if (c.cooldown_sec != null && $('usmom_cooldown')) $('usmom_cooldown').value = String(Math.round(Number(c.cooldown_sec) / 60 * 10) / 10); setN('usmom_max_daily', c.max_daily); setN('usmom_slot_money', c.slot_money); setN('usmom_rsi_min', c.rsi_min); setN('usmom_rsi_max', c.rsi_max); setN('usmom_vol_mult', c.vol_mult); setN('usmom_vol_win', c.vol_win); setN('usmom_chase_lookback', c.chase_lookback_min); setN('usmom_pullback_lookback', c.pullback_lookback_min); setN('usmom_pullback_min', c.pullback_min_pct); setN('usmom_pullback_max', c.pullback_max_pct); if ($('usmom_bt_codes')) $('usmom_bt_codes').value = code; }) .catch(e => console.warn('[usmom stock_cfg]', e)); } function usmomSaveStockCfg() { const code = ($('usmom_stock_code')?.value || '').trim().toUpperCase(); if (!code) { alert('종목을 선택하세요'); return; } const numOrNull = (id) => { const el = $(id); if (!el || el.value === '' || el.value == null) return null; const n = Number(el.value); return Number.isFinite(n) ? n : null; }; const body = { code, exchange: ($('usmom_stock_exch')?.value || 'NASD').trim(), symbol: code, name: code, stock_group: ($('usmom_stock_group')?.value || 'STOCK'), sl_pct: numOrNull('usmom_sl'), tp_pct: numOrNull('usmom_tp'), tp_max_pct: numOrNull('usmom_tp_max'), shoulder_min_high_pct: numOrNull('usmom_smin'), shoulder_cut_pct: numOrNull('usmom_scut'), trail_pct: numOrNull('usmom_trail'), trail_arm_pct: numOrNull('usmom_trail_arm'), ratchet_tiers: ($('usmom_ratchet')?.value || '').trim() || null, max_hold_bars: numOrNull('usmom_max_hold'), cooldown_sec: (() => { const m = numOrNull('usmom_cooldown'); return m == null ? null : Math.round(m * 60); })(), max_daily: numOrNull('usmom_max_daily'), slot_money: numOrNull('usmom_slot_money'), rsi_min: numOrNull('usmom_rsi_min'), rsi_max: numOrNull('usmom_rsi_max'), vol_mult: numOrNull('usmom_vol_mult'), vol_win: numOrNull('usmom_vol_win'), chase_lookback_min: numOrNull('usmom_chase_lookback'), pullback_lookback_min: numOrNull('usmom_pullback_lookback'), pullback_min_pct: numOrNull('usmom_pullback_min'), pullback_max_pct: numOrNull('usmom_pullback_max'), setup_vol_max_mult: numOrNull('usmom_setup_vol'), setup_bear_bars_min: numOrNull('usmom_setup_bear'), high_chase_thr: numOrNull('usmom_high_chase'), max_daily_chg: numOrNull('usmom_max_daily_chg'), min_price: numOrNull('usmom_min_price'), ema_fast_period: numOrNull('usmom_ema_fast'), ema_slow_period: numOrNull('usmom_ema_slow'), use_defense_filters: $('usmom_use_defense')?.checked ? 1 : 0, use_high_chase_filter: $('usmom_use_high_chase_f')?.checked ? 1 : 0, use_daily_range_filter: $('usmom_use_daily_range')?.checked ? 1 : 0, use_ema_filter: $('usmom_use_ema_f')?.checked ? 1 : 0, use_rsi_max_filter: $('usmom_use_rsi_max_f')?.checked ? 1 : 0, pattern_breakout: $('usmom_pat_breakout')?.checked ? 1 : 0, pattern_pullback: $('usmom_pat_pullback')?.checked ? 1 : 0, }; fetch('/api/us_momentum/stock_cfg', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) .then(r => r.json()) .then(d => { if (d.error) throw new Error(d.error); alert('✅ ' + code + ' 종목 파람 저장'); usmomLoadStocks(false); }) .catch(e => alert('종목 저장 실패: ' + e)); } function usmomClearStockCfg() { const code = ($('usmom_stock_code')?.value || '').trim().toUpperCase(); if (!code) return; if (!confirm(code + ' 종목 핀을 삭제하고 전역 US_MOMENTUM_* 로 복귀할까요?')) return; fetch('/api/us_momentum/stock_cfg?code=' + encodeURIComponent(code), { method: 'DELETE' }) .then(r => r.json()) .then(d => { if (d.error) throw new Error(d.error); usmomLoadStocks(false); }) .catch(e => alert('삭제 실패: ' + e)); } const USMOM_OPT_LS_KEY = 'kis_usmom_stock_optuna_job'; let _usmomOptJobId = localStorage.getItem(USMOM_OPT_LS_KEY) || ''; let _usmomOptPollTimer = null; /** @type {{source:string, rank:number}|null} */ let _usmomOptSelected = null; window._usmomOptLastSummary = null; function usmomOptSetStatus(html, isErr) { const el = $('usmom_opt_status'); if (!el) return; el.innerHTML = html; el.style.color = isErr ? 'var(--danger, #f85149)' : 'var(--muted)'; } function usmomOptSyncSelectedButtons() { const has = !!( _usmomOptSelected && _usmomOptSelected.source && _usmomOptSelected.rank ); const fl = $('usmom_opt_btn_fill_sel'); const ap = $('usmom_opt_btn_apply_sel'); if (fl) fl.disabled = !has; if (ap) ap.disabled = !has; const sel = $('usmom_opt_detail_sel'); if (sel) { sel.textContent = has ? `${_usmomOptSelected.source} #${_usmomOptSelected.rank}` : '미선택'; } } function usmomOptSetButtons(running, done) { const st = $('usmom_opt_btn_start'); const sp = $('usmom_opt_btn_stop'); const ap = $('usmom_opt_btn_apply'); const fl = $('usmom_opt_btn_fill'); if (st) st.disabled = !!running; if (sp) sp.disabled = !running; if (ap) ap.disabled = !done; if (fl) fl.disabled = !done; const box = $('usmom_opt_results_box'); if (box) { if (done) { box.style.display = 'block'; box.dataset.forceShow = '1'; } else if (running) { box.style.display = 'none'; box.dataset.forceShow = ''; } } usmomOptSyncSelectedButtons(); } function usmomOptFmtUsdOrNum(v, digits) { const n = Number(v); if (!Number.isFinite(n)) return '—'; if (digits == null) return n.toLocaleString(undefined, { maximumFractionDigits: 2 }); return n.toFixed(digits); } function usmomOptRenderTopTable(tbodyId, rows, source) { const tb = $(tbodyId); if (!tb) return; if (!rows || !rows.length) { tb.innerHTML = `${source} 후보 없음`; return; } const src = source; tb.innerHTML = rows.map(r => { const rank = r.rank || 1; const sel = _usmomOptSelected && _usmomOptSelected.source === src && Number(_usmomOptSelected.rank) === Number(rank); const outline = sel ? 'outline:1px solid var(--accent)' : ''; return ` ${rank} #${r.optuna_trial_number ?? '—'} ${usmomOptFmtUsdOrNum(r.total_trades, 0)} ${usmomOptFmtUsdOrNum(r.win_rate, 1)}% ${usmomOptFmtUsdOrNum(r.pf, 2)} ${usmomOptFmtUsdOrNum(r.total_pnl)} `; }).join(''); } function usmomStockOptunaRenderResults(sum) { window._usmomOptLastSummary = sum || null; const box = $('usmom_opt_results_box'); if (!sum) { if (box) box.style.display = 'none'; return; } if (box) { box.style.display = 'block'; box.dataset.forceShow = '1'; } usmomOptRenderTopTable('usmom_opt_top5_learn', sum.top5_learn || [], 'learn'); usmomOptRenderTopTable('usmom_opt_top5_gated', sum.top5_gated || [], 'gated'); } function usmomOptSyncDatesFromMain() { const s = ($('usmom_start')?.value || '').trim(); const e = ($('usmom_end')?.value || '').trim(); if ($('usmom_opt_start') && s) $('usmom_opt_start').value = s; if ($('usmom_opt_end') && e) $('usmom_opt_end').value = e; } function usmomOptSyncDatesToMain() { const s = ($('usmom_opt_start')?.value || '').trim(); const e = ($('usmom_opt_end')?.value || '').trim(); if ($('usmom_start') && s) setDateVal('usmom_start', s); if ($('usmom_end') && e) setDateVal('usmom_end', e); } function usmomStockOptunaRender(job) { if (!job) return; const st = job.status || ''; const prog = job.progress || {}; const pct = Number(prog.pct || 0); const doneN = prog.trials_done ?? 0; const totN = prog.trials_total ?? job.trials ?? '—'; const fill = $('usmom_opt_fill'); if (fill) { fill.style.width = `${Math.min(100, Math.max(0, pct))}%`; fill.classList.toggle('is-done', st === 'done'); fill.classList.toggle('is-error', st === 'error'); } if ($('usmom_opt_prog_label')) { const met = optunaBestMetricsText(prog, job); $('usmom_opt_prog_label').textContent = `trial ${doneN} / ${totN} (${pct}%)` + (job.symbol ? ` · ${job.symbol}` : '') + (met ? ` · ${met}` : ''); } const log = job.log_path ? `
${job.log_path}` : ''; usmomOptSetStatus( `${job.label || '종목 Optuna'} · ${st}` + (job.error ? `
❌ ${job.error}` : '') + (job.result_json ? `
JSON: ${job.result_json}` : '') + log, st === 'error' ); const tail = $('usmom_opt_log_tail'); if (tail) { const t = (job.log_tail || '').trim(); if (t) { tail.textContent = t; tail.classList.remove('d-none'); } else if (st !== 'running') { tail.classList.add('d-none'); } } usmomOptSetButtons(st === 'running', st === 'done'); if (st === 'done' && job.result_summary) { usmomStockOptunaRenderResults(job.result_summary); } // 상단 네비 프로그레스도 동일 갱신 (Optuna 탭과 공유 바) if (typeof optunaSetNav === 'function') optunaSetNav(job); } async function usmomStockOptunaView(source, rank) { if (!_usmomOptJobId) { _usmomOptJobId = localStorage.getItem(USMOM_OPT_LS_KEY) || ''; } if (!_usmomOptJobId) { alert('종목 Optuna job 없음'); return; } const src = source || 'learn'; const rk = Number(rank) || 1; _usmomOptSelected = { source: src, rank: rk }; usmomOptSyncSelectedButtons(); const title = $('usmom_opt_detail_title'); const met = $('usmom_opt_detail_metrics'); const pre = $('usmom_opt_detail_params'); if (title) title.textContent = `로딩 ${src} #${rk}…`; if (met) met.textContent = '불러오는 중…'; try { const qs = new URLSearchParams({ job_id: _usmomOptJobId, source: src, rank: String(rk), }); const r = await fetch('/api/optuna/candidate?' + qs.toString()); const j = await r.json(); if (!j.ok) { if (met) met.textContent = '❌ ' + (j.error || '실패'); if (pre) { pre.textContent = '—'; pre.classList.add('d-none'); } return; } const m = j.metrics || {}; if (title) { title.textContent = `${j.strategy || 'us_momentum'} · ${m.label || src} · trial ${ m.optuna_trial_number != null ? '#' + m.optuna_trial_number : '—' }`; } if (met) { met.innerHTML = `거래 ${usmomOptFmtUsdOrNum(m.total_trades, 0)} · ` + `승률 ${usmomOptFmtUsdOrNum(m.win_rate, 1)}% · ` + `PF ${usmomOptFmtUsdOrNum(m.pf, 2)} · ` + `PnL ${usmomOptFmtUsdOrNum(m.total_pnl)}` + (j.start && j.end ? ` · ${j.start}~${j.end}` : ''); } if (pre) { pre.textContent = JSON.stringify(j.params_preview || j.params_full || {}, null, 2); pre.classList.remove('d-none'); } window._optunaJobId = _usmomOptJobId; window._optunaCandidateCache = { strategy: j.strategy || 'us_momentum', start: j.start, end: j.end, params: j.params_full || j.params_preview || {}, }; usmomStockOptunaRenderResults(window._usmomOptLastSummary); } catch (e) { if (met) met.textContent = '오류: ' + e; } } async function usmomStockOptunaFillSelected() { if (!_usmomOptSelected) { alert('먼저 Top에서「보기」로 후보를 고르세요'); return; } return usmomStockOptunaFillForm(_usmomOptSelected.source, _usmomOptSelected.rank); } async function usmomStockOptunaApplySelected() { if (!_usmomOptSelected) { alert('먼저 Top에서「보기」로 후보를 고르세요'); return; } return usmomStockOptunaApply(_usmomOptSelected.source, _usmomOptSelected.rank); } async function usmomStockOptunaStart() { const code = ($('usmom_stock_code')?.value || '').trim().toUpperCase(); if (!code) { alert('종목을 선택하세요'); return; } usmomOptSyncDatesFromMain(); let start = ($('usmom_opt_start')?.value || '').trim() || ($('usmom_start')?.value || '').trim(); let end = ($('usmom_opt_end')?.value || '').trim() || ($('usmom_end')?.value || '').trim(); if (!start || !end) { alert('시작·종료일을 입력하세요'); return; } usmomOptSyncDatesToMain(); if ($('usmom_bt_codes')) $('usmom_bt_codes').value = code; const trials = parseInt($('usmom_opt_trials')?.value || '100', 10) || 100; const mode = ($('usmom_opt_mode')?.value || 'tpe').trim() || 'tpe'; const candleSrc = $('opt_candle_source')?.value || ''; const tickSrc = $('opt_tick_source')?.value || ''; const obSrc = $('opt_ob_source')?.value || ''; if (!confirm( `종목 Optuna 시작?\n${code} (1종목 유니버스)\n${start}~${end} trials=${trials}\n` + `완료 후 「폼에 넣기」→백테 검증 · 「종목행에 적용」→ stock_config\n(전역 US_MOMENTUM_* 미적용)` )) return; try { const r = await fetch('/api/optuna/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ strategy: 'us_momentum', symbol: code, start, end, trials, mode, candle_source: candleSrc, tick_source: tickSrc, ob_source: obSrc, }), }); const j = await r.json(); if (!j.ok) { alert('❌ ' + (j.error || '시작 실패')); return; } _usmomOptJobId = j.job.job_id; localStorage.setItem(USMOM_OPT_LS_KEY, _usmomOptJobId); usmomStockOptunaRender(j.job); usmomStockOptunaStartPolling(); } catch (e) { alert('오류: ' + e); } } async function usmomStockOptunaStop() { if (!_usmomOptJobId) return; if (!confirm('종목 Optuna를 중지할까요?')) return; try { const r = await fetch('/api/optuna/stop/' + encodeURIComponent(_usmomOptJobId), { method: 'POST' }); const j = await r.json(); if (!j.ok) { alert(j.error || '중지 실패'); return; } usmomStockOptunaRender(j.job); if (_usmomOptPollTimer) { clearInterval(_usmomOptPollTimer); _usmomOptPollTimer = null; } } catch (e) { alert('오류: ' + e); } } async function usmomStockOptunaPollOnce() { if (!_usmomOptJobId) return; try { const r = await fetch('/api/optuna/status/' + encodeURIComponent(_usmomOptJobId)); const j = await r.json(); if (!j.ok) { usmomOptSetStatus('job 없음', true); _usmomOptJobId = ''; localStorage.removeItem(USMOM_OPT_LS_KEY); usmomOptSetButtons(false, false); const fill = $('usmom_opt_fill'); if (fill) { fill.style.width = '0%'; fill.classList.remove('is-done', 'is-error'); } if ($('usmom_opt_prog_label')) $('usmom_opt_prog_label').textContent = 'trial —'; return; } usmomStockOptunaRender(j.job); const st = j.job.status; if (st === 'done' || st === 'error') { if (_usmomOptPollTimer) { clearInterval(_usmomOptPollTimer); _usmomOptPollTimer = null; } } } catch (e) { usmomOptSetStatus('폴링 오류: ' + e, true); } } function usmomStockOptunaStartPolling() { if (_usmomOptPollTimer) clearInterval(_usmomOptPollTimer); usmomStockOptunaPollOnce(); _usmomOptPollTimer = setInterval(usmomStockOptunaPollOnce, 4000); } async function usmomStockOptunaFillForm(source, rank) { /* source/rank 없으면 학습1위(진행바 best). Top「폼」·선택 버튼에서 지정 가능. */ if (!_usmomOptJobId) { _usmomOptJobId = localStorage.getItem(USMOM_OPT_LS_KEY) || ''; } if (!_usmomOptJobId) { alert('완료된 종목 Optuna job 없음'); return; } const src = (source || 'learn').toString(); const rk = Number(rank) || 1; _usmomOptSelected = { source: src, rank: rk }; usmomOptSyncSelectedButtons(); const code = ($('usmom_stock_code')?.value || '').trim().toUpperCase() || ($('usmom_bt_codes')?.value || '').trim().toUpperCase(); try { const qs = new URLSearchParams({ job_id: _usmomOptJobId, source: src, rank: String(rk), }); const r = await fetch('/api/optuna/candidate?' + qs.toString()); const j = await r.json(); if (!j.ok) { alert('❌ ' + (j.error || '후보 없음')); return; } window._optunaJobId = _usmomOptJobId; window._optunaCandidateCache = { strategy: j.strategy || 'us_momentum', start: j.start, end: j.end, params: j.params_full || j.params_preview || {}, }; // 이미 🇺🇸탭 — tab.click 스킵 (핀 cfg 재로드로 덮어쓰기 방지) await optunaFillFormFromCandidate(src, rk); if (code && $('usmom_bt_codes')) $('usmom_bt_codes').value = code; if (j.start) { setDateVal('usmom_start', j.start); if ($('usmom_opt_start')) $('usmom_opt_start').value = j.start; } if (j.end) { setDateVal('usmom_end', j.end); if ($('usmom_opt_end')) $('usmom_opt_end').value = j.end; } } catch (e) { alert('폼에 넣기 오류: ' + e); } } async function usmomStockOptunaApply(source, rank) { if (!_usmomOptJobId) { _usmomOptJobId = localStorage.getItem(USMOM_OPT_LS_KEY) || ''; } if (!_usmomOptJobId) { alert('완료된 종목 Optuna job 없음'); return; } const code = ($('usmom_stock_code')?.value || '').trim().toUpperCase(); if (!code) { alert('종목 선택 필요'); return; } const src = (source || 'learn').toString(); const rk = Number(rank) || 1; _usmomOptSelected = { source: src, rank: rk }; usmomOptSyncSelectedButtons(); if (!confirm( `${code} ${src} #${rk} → us_momentum_stock_config?\n` + `(전역 US_MOMENTUM_* / 다단트레일 미변경)` )) return; try { const r = await fetch('/api/optuna/apply', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ job_id: _usmomOptJobId, source: src, rank: rk, symbol: code, exchange: ($('usmom_stock_exch')?.value || 'NASD').trim(), stock_group: ($('usmom_stock_group')?.value || 'STOCK').trim(), }), }); const j = await r.json(); if (!j.ok) { alert('❌ ' + (j.error || '적용 실패')); return; } alert( `✅ ${code} 종목행 적용 (${src} #${rk})\n` + `PnL=${j.metrics?.total_pnl} trades=${j.metrics?.total_trades}\n` + (j.apply_target || '') ); usmomLoadStocks(false); } catch (e) { alert('오류: ' + e); } } /** US_MOMENTUM_* 만 저장 — 국내 MOMENTUM_/SCALP_ 미오염 */ function saveUsMomentumConfig() { const body = { sl_pct: parseFloat($('usmom_sl')?.value), tp_pct: parseFloat($('usmom_tp')?.value), tp_max_pct: parseFloat($('usmom_tp_max')?.value), slot_money: parseInt($('usmom_slot_money')?.value || '0', 10) || 0, slots: parseInt($('usmom_slots')?.value || '4', 10) || 4, total_budget_krw: parseInt($('usmom_total_budget')?.value || '0', 10), cooldown_min: parseFloat($('usmom_cooldown')?.value || '0'), shoulder_min_high: parseFloat($('usmom_smin')?.value), shoulder_cut_pct: parseFloat($('usmom_scut')?.value), trail_pct: parseFloat($('usmom_trail')?.value || '0'), trail_arm_pct: parseFloat($('usmom_trail_arm')?.value || '0'), max_hold_bars: parseInt($('usmom_max_hold')?.value || '0', 10), ratchet_tiers: ($('usmom_ratchet')?.value || '').trim(), time_start: parseInt($('usmom_time_start')?.value || '2230', 10), time_end: parseInt($('usmom_time_end')?.value || '500', 10), sell_time_end: parseInt($('usmom_sell_time_end')?.value || '630', 10), max_daily: parseInt($('usmom_max_daily')?.value || '5', 10), high_chase_thr: parseFloat($('usmom_high_chase')?.value || '0.96'), max_daily_chg: parseFloat($('usmom_max_daily_chg')?.value || '20'), min_price: parseFloat($('usmom_min_price')?.value || '1'), max_loss_krw: parseInt($('usmom_max_loss')?.value || '0', 10), min_margin: parseFloat($('usmom_min_margin')?.value || '0.2'), use_defense_filters: !!($('usmom_use_defense')?.checked), use_high_chase_filter: !!($('usmom_use_high_chase_f')?.checked), use_daily_range_filter: !!($('usmom_use_daily_range')?.checked), use_ema_filter: !!($('usmom_use_ema_f')?.checked), use_rsi_max_filter: !!($('usmom_use_rsi_max_f')?.checked), pattern_breakout: !!($('usmom_pat_breakout')?.checked), pattern_pullback: !!($('usmom_pat_pullback')?.checked), chase_lookback_min: parseInt($('usmom_chase_lookback')?.value || '10', 10), pullback_lookback_min: parseInt($('usmom_pullback_lookback')?.value || '15', 10), pullback_min_pct: parseFloat($('usmom_pullback_min')?.value || '0.3'), pullback_max_pct: parseFloat($('usmom_pullback_max')?.value || '3.0'), setup_vol_max_mult: parseFloat($('usmom_setup_vol')?.value || '0.8'), setup_bear_bars_min: parseInt($('usmom_setup_bear')?.value || '1', 10), ema_fast_period: parseInt($('usmom_ema_fast')?.value || '9', 10), ema_slow_period: parseInt($('usmom_ema_slow')?.value || '21', 10), mom_rsi_min: parseFloat($('usmom_rsi_min')?.value), mom_rsi_max: parseFloat($('usmom_rsi_max')?.value), mom_vol_mult: parseFloat($('usmom_vol_mult')?.value), mom_vol_win: parseInt($('usmom_vol_win')?.value || '5', 10), mom_max_from_open_pct: parseFloat($('usmom_max_from_open')?.value || '999'), mom_min_from_open_pct: parseFloat($('usmom_min_from_open')?.value || '-999'), daily_trail_tiers: ($('usmom_daily_trail_tiers')?.value || '').trim(), daily_profit_mode: ($('usmom_daily_profit_mode')?.value || 'trailing'), daily_profit_enabled: !!($('usmom_daily_profit_enabled')?.checked), daily_risk_budget_enabled: !!($('usmom_daily_risk_budget_enabled')?.checked), eod_enabled: !!($('usmom_eod_enabled')?.checked), eod_hm: ($('usmom_eod_hm')?.value || '').trim() || '05:00', }; fetch('/api/backtest/us_momentum/save_config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) .then(r => r.json()) .then(d => { if (d.error) throw new Error(d.error); alert('✅ 해외 모멘텀 설정 저장 (' + (d.saved_keys || []).length + '키 · US_MOMENTUM_* only)'); }) .catch(err => alert('해외 모멘텀 저장 오류: ' + err)); } function updateUsMomFxHint() { const apply = (fx) => { if (fx > 0) setDisplayFxRate(fx); const el = $('usmom_fx_hint'); if (!el) return; const r = getDisplayFxRate(); if (!(r > 0)) { el.textContent = '$↔원 = 표시용(캐시/폴백)'; return; } const slot = Number($('usmom_slot_money')?.value || 0); const bud = Number($('usmom_total_budget')?.value || 0); el.textContent = `FX ${r.toFixed(2)} · 1회≈${fmtWon(Math.round(slot * r))}원 · 한도≈${fmtWon(Math.round(bud * r))}원`; }; const cached = getDisplayFxRate(); if (cached > 0) apply(cached); fetch('/api/fx/usd_krw') .then(r => r.json()) .then(j => { const fx = Number(j.display_rate || 0); if (fx > 0) apply(fx); }) .catch(() => {}); } function fillUsMomentumFormFromApi(m) { if (!m || typeof m !== 'object') return; const set = (id, val) => { if (val !== null && val !== undefined && $(id)) $(id).value = val; }; set('usmom_rsi_min', m.mom_rsi_min); set('usmom_rsi_max', m.mom_rsi_max); set('usmom_vol_mult', m.mom_vol_mult); set('usmom_vol_win', m.mom_vol_win); set('usmom_time_start', m.mom_time_start_hm || m.time_start); set('usmom_time_end', m.mom_time_end_hm || m.time_end); set('usmom_sell_time_end', m.sell_time_end); if (m.eod_enabled !== undefined && $('usmom_eod_enabled')) { $('usmom_eod_enabled').checked = !!m.eod_enabled; } if (m.eod_hm != null) set('usmom_eod_hm', m.eod_hm); set('usmom_sl', m.sl_pct); set('usmom_tp', m.tp_pct); set('usmom_tp_max', m.tp_max_pct); set('usmom_smin', m.shoulder_min_high); set('usmom_scut', m.shoulder_cut_pct); if (m.trail_pct != null) set('usmom_trail', m.trail_pct); if (m.trail_arm_pct != null) set('usmom_trail_arm', m.trail_arm_pct); if (m.max_hold_bars != null) set('usmom_max_hold', m.max_hold_bars); if ($('usmom_ratchet')) $('usmom_ratchet').value = (m.ratchet_tiers != null ? String(m.ratchet_tiers) : ''); set('usmom_cooldown', m.cooldown_min); set('usmom_max_daily', m.max_daily); set('usmom_slot_money', m.slot_money); set('usmom_slots', m.mom_slots || m.slots); if (m.total_budget_krw != null) set('usmom_total_budget', m.total_budget_krw); set('usmom_high_chase', m.high_chase_thr); set('usmom_max_daily_chg', m.max_daily_chg); set('usmom_min_price', m.min_price); set('usmom_max_loss', m.max_loss_krw); set('usmom_min_margin', m.min_margin); set('usmom_max_from_open', m.mom_max_from_open_pct); set('usmom_min_from_open', m.mom_min_from_open_pct); if ($('usmom_pat_breakout') && m.pattern_breakout !== undefined) { $('usmom_pat_breakout').checked = !!m.pattern_breakout; } if ($('usmom_pat_pullback') && m.pattern_pullback !== undefined) { $('usmom_pat_pullback').checked = !!m.pattern_pullback; } set('usmom_chase_lookback', m.chase_lookback_min); set('usmom_pullback_lookback', m.pullback_lookback_min); set('usmom_pullback_min', m.pullback_min_pct); set('usmom_pullback_max', m.pullback_max_pct); set('usmom_setup_vol', m.setup_vol_max_mult); set('usmom_setup_bear', m.setup_bear_bars_min); if (m.use_defense_filters !== undefined && $('usmom_use_defense')) { $('usmom_use_defense').checked = !!m.use_defense_filters; } if ($('usmom_use_high_chase_f') && m.use_high_chase_filter !== undefined) { $('usmom_use_high_chase_f').checked = !!m.use_high_chase_filter; } if ($('usmom_use_daily_range') && m.use_daily_range_filter !== undefined) { $('usmom_use_daily_range').checked = !!m.use_daily_range_filter; } if ($('usmom_use_ema_f') && m.use_ema_filter !== undefined) { $('usmom_use_ema_f').checked = !!m.use_ema_filter; } if ($('usmom_use_rsi_max_f') && m.use_rsi_max_filter !== undefined) { $('usmom_use_rsi_max_f').checked = !!m.use_rsi_max_filter; } if (m.ema_fast_period != null) set('usmom_ema_fast', m.ema_fast_period); if (m.ema_slow_period != null) set('usmom_ema_slow', m.ema_slow_period); if (m.daily_profit_enabled !== undefined && $('usmom_daily_profit_enabled')) { $('usmom_daily_profit_enabled').checked = !!m.daily_profit_enabled; } if (m.daily_risk_budget_enabled !== undefined && $('usmom_daily_risk_budget_enabled')) { $('usmom_daily_risk_budget_enabled').checked = !!m.daily_risk_budget_enabled; } if (m.daily_trail_tiers != null) set('usmom_daily_trail_tiers', m.daily_trail_tiers); if (m.daily_profit_mode != null) set('usmom_daily_profit_mode', m.daily_profit_mode); updateUsMomFxHint(); } /** 영구구독 US 전 종목 → KIS 해외 1분봉 → ws_candles.market=US */ function usmomFetchCandles(tf) { const start = $('usmom_start')?.value || ''; const end = $('usmom_end')?.value || ''; if (!start || !end) { alert('시작일·종료일을 먼저 넣으세요.'); return; } const st = $('usmom_fetch_status'); if (st) st.textContent = '수집 시작…'; fetch('/api/ws_candles/fetch_us', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ start, end, tf: tf || 1, rollup_3m: true }), }) .then(r => r.json()) .then(d => { if (d.error) throw new Error(d.error); const jobId = d.job_id; if (!jobId) throw new Error('job_id 없음'); const poll = () => { fetch('/api/ws_candles/fetch_us/status/' + jobId) .then(r => r.json()) .then(j => { if (st) { st.textContent = `${j.status || '?'} · ${j.current || ''} saved=${j.saved || 0}` + (j.error ? ' · ' + j.error : ''); } if (j.status === 'running') setTimeout(poll, 1500); else if (j.status === 'done' && st) { st.textContent = `완료 · 종목 ${j.codes || 0} · 저장 ${j.saved || 0}봉` + (j.rollup_3m ? ` · 3m롤업 ${j.rollup_3m}` : ''); } }) .catch(e => { if (st) st.textContent = '폴링 오류: ' + e; }); }; poll(); }) .catch(err => { if (st) st.textContent = '실패: ' + err; alert('US 봉 수집 오류: ' + 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), e_min_chg_pct: parseFloat($('mom_e_min_chg')?.value || '0.2'), 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', daily_profit_enabled: !!($('mom_daily_profit_enabled')?.checked), daily_risk_budget_enabled: !!($('mom_daily_risk_budget_enabled')?.checked), eod_enabled: !!($('mom_eod_enabled')?.checked), eod_hm: ($('mom_eod_hm') && $('mom_eod_hm').value.trim()) || '15:25', backtest_skip_pre_subscribe: !!($('mom_skip_pre_sub')?.checked), }; if (!confirm('💾 모멘텀 봇(MOMENTUM_*)에 현재 탭 값을 저장할까요?\n실행 중이면 다음 루프부터 반영됩니다.\n다단트레일: ' + (body.daily_profit_enabled ? 'ON' : 'OFF') + ' · 리스크버짓(B): ' + (body.daily_risk_budget_enabled ? 'ON' : 'OFF'))) 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) { fillTradePnLContext(momCtx, { label: '백테 MOMENTUM', totalBudget: Number(p.total_budget_krw || 0), peakCum: Number(s.peak_cum_pnl || 0), peakAt: s.peak_cum_at || '', totalPnl: s.total_pnl, }); } setTxt('mom_total', (s.total_trades || 0) + '건'); setTxt('mom_winrate', (s.win_rate || 0) + '%'); colorPnl($('mom_winrate'), (s.win_rate || 0) - 50); setTxt('mom_pnl', fmtKrw(s.total_pnl)); colorPnl($('mom_pnl'), s.total_pnl); setTxt('mom_pf', (s.profit_factor || 0) >= 999 ? '∞' : String(s.profit_factor || 0)); colorPnl($('mom_pf'), (s.profit_factor || 0) - 1); setTxt('mom_mdd', '-' + fmtWon(s.max_drawdown) + '원'); setTxt('mom_hold', (s.avg_hold_min || 0) + '분'); const momSign = v => (v > 0 ? '+' : ''); setTxt('mom_bot_pct', momSign(s.bot_pct || 0) + (s.bot_pct || 0) + '%'); colorPnl($('mom_bot_pct'), s.bot_pct || 0); setTxt('mom_daily_avg_pct', momSign(s.daily_avg_pct || 0) + (s.daily_avg_pct || 0) + '%'); colorPnl($('mom_daily_avg_pct'), s.daily_avg_pct || 0); if (s.budget_warning) { if (momPb) momPb.innerHTML += `
💰 ${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 covLabel = fmtTickCoverageLabel(tickMeta); 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}건 · 분봉커버 ${covLabel} | ` + `진입 틱${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})` + ` · 최종 ${fmtKrw(s.total_pnl)}`; if (momPb) momPb.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_period: parseInt($('bt_rsi_period')?.value || '3', 10), 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), vol_mult: parseFloat($('bt_vol_mult')?.value || '0'), 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), time_start_hm: parseInt($('bt_time_start')?.value || '830', 10), time_end_hm: parseInt($('bt_time_end')?.value || '1530', 10), max_daily: parseInt($('bt_max_daily')?.value || '3', 10), skip_hts_scan_dupes: !!($('bt_skip_hts_dupes')?.checked), require_reversal_candle: !!($('bt_require_reversal')?.checked), slot_money: parseFloat($('bt_slot')?.value || '0'), max_stocks: parseInt($('bt_max_stocks')?.value || '3', 10), total_budget_krw: parseFloat($('bt_total_budget')?.value || '0'), eod_enabled: !!($('bt_eod_enabled')?.checked), eod_hm: ($('bt_eod_hm') && $('bt_eod_hm').value.trim()) || '15:25', }; if (!confirm(`💾 스캘핑 봇에 아래 파라미터를 저장합니까?\n\n` + `RSI기간: ${body.rsi_period} / 과매도: ${body.rsi_oversold} / 과열: ${body.rsi_overbought}\n` + `손절: ${body.sl_pct}% / 익절: ${body.tp_pct}% / 낙폭: ${body.drop_rate}% / 거래량×: ${body.vol_mult}\n` + `EOD: ${body.eod_enabled ? 'ON' : 'OFF'} ${body.eod_hm}\n` + `\n⚠️ 봇이 실행 중이면 다음 루프부터 즉시 반영됩니다.`)) return; fetch('/api/backtest/scalping/save_config', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body), }).then(r => r.json()).then(d => { if (d.error) { alert('❌ ' + d.error); return; } alert(`✅ 저장 완료 (env_id: ${d.env_id})\n저장 키: ${d.saved_keys?.join(', ')}`); }).catch(e => alert('오류: ' + e)); } // 방어 ON/OFF 토글은 클릭 즉시 DB 저장 (실시간 반영) async function saveScalpDefenseRealtime() { const enabled = !!($('bt_use_defense')?.checked); try { const r = await fetch('/api/backtest/scalping/save_config', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ use_defense_filters: enabled }), }); const d = await r.json(); if (d.error) { alert('❌ 방어로직 저장 실패: ' + d.error); return; } // DB 저장만 즉시 반영 — 날짜·파라미터 조정 후 「백테스트 실행」으로 확인 } catch (e) { alert('오류: ' + e); } } async function saveScalpMacdRealtime() { const enabled = !!($('bt_use_macd')?.checked); try { const r = await fetch('/api/backtest/scalping/save_config', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ use_macd_cross: enabled }), }); const d = await r.json(); if (d.error) { alert('❌ MACD 모드 저장 실패: ' + d.error); return; } // MACD 백테는 reversal 보다 무거움 — 저장만 즉시 반영, 백테는 사용자가 실행 } catch (e) { alert('오류: ' + e); } } function fillTailFormFromApi(t) { if (!t || typeof t !== 'object') return; const set = (id, val) => { if (val !== null && val !== undefined && $(id)) $(id).value = val; }; set('tl_drop', t.drop); set('tl_rec', t.rec); set('tl_tail', t.tail_ratio); set('tl_sl', t.sl_pct); set('tl_tp', t.tp_pct); set('tl_smin', t.smin); set('tl_scut', t.scut); if ($('tl_ratchet')) $('tl_ratchet').value = (t.ratchet_tiers != null ? String(t.ratchet_tiers) : ''); if (t.trail_pct != null) set('tl_trail', t.trail_pct); if (t.trail_arm_pct != null) set('tl_trail_arm', t.trail_arm_pct); set('tl_cool', t.cool); set('tl_rsi', t.rsi); set('tl_ts', t.time_start); set('tl_te', t.time_end); if (t.eod_enabled !== undefined && $('tl_eod_enabled')) { $('tl_eod_enabled').checked = !!t.eod_enabled; } if (t.eod_hm != null) set('tl_eod_hm', t.eod_hm); set('tl_maxd', t.max_daily); if (t.symbol_daily_loss_limit_krw != null) set('tl_symbol_loss_krw', t.symbol_daily_loss_limit_krw); if (t.symbol_daily_loss_limit_pct != null) set('tl_symbol_loss_pct', t.symbol_daily_loss_limit_pct); if (t.reentry_min_edge_krw != null) set('tl_reentry_min_edge', t.reentry_min_edge_krw); 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 !== false; if ($('tl_tick_fallback_ohlc')) $('tl_tick_fallback_ohlc').checked = !!t.backtest_tick_fallback_ohlc; 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 (t.daily_profit_enabled !== undefined && $('tl_daily_profit_enabled')) { $('tl_daily_profit_enabled').checked = !!t.daily_profit_enabled; } if (t.daily_risk_budget_enabled !== undefined && $('tl_daily_risk_budget_enabled')) { $('tl_daily_risk_budget_enabled').checked = !!t.daily_risk_budget_enabled; } 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; } if ($('tl_daily_trail_arm') && t.daily_trail_arm_krw != null) { $('tl_daily_trail_arm').value = String(t.daily_trail_arm_krw); } if ($('tl_daily_trail_drop') && t.daily_trail_drop_pct != null) { $('tl_daily_trail_drop').value = String(t.daily_trail_drop_pct); } // 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_e_min_chg', m.e_min_chg_pct); set('mom_time_end_buy', m.mom_time_end_hm); set('mom_time_start', m.mom_time_start_hm); if (m.eod_enabled !== undefined && $('mom_eod_enabled')) { $('mom_eod_enabled').checked = !!m.eod_enabled; } if (m.backtest_skip_pre_subscribe !== undefined && $('mom_skip_pre_sub')) { $('mom_skip_pre_sub').checked = !!m.backtest_skip_pre_subscribe; } if (m.eod_hm != null) set('mom_eod_hm', m.eod_hm); set('mom_sl', m.sl_pct); set('mom_tp', m.tp_pct); set('mom_tp_max', m.tp_max_pct); set('mom_smin', m.shoulder_min_high); set('mom_scut', m.shoulder_cut_pct); if (m.trail_pct != null) set('mom_trail', m.trail_pct); if (m.trail_arm_pct != null) set('mom_trail_arm', m.trail_arm_pct); if (m.max_hold_bars != null) set('mom_max_hold', m.max_hold_bars); if ($('mom_ratchet')) $('mom_ratchet').value = (m.ratchet_tiers != null ? String(m.ratchet_tiers) : ''); set('mom_cooldown', m.cooldown_min); set('mom_max_daily', m.max_daily); set('mom_slot_money', m.slot_money); set('mom_slots', m.mom_slots); if (m.total_budget_krw != null) set('mom_total_budget', m.total_budget_krw); const momPw = $('mom_portfolio_warn'); if (momPw) { if (m.portfolio_ui_warning) { momPw.textContent = '⚠️ ' + m.portfolio_ui_warning; momPw.style.display = 'block'; } else { momPw.style.display = 'none'; momPw.textContent = ''; } } set('mom_high_chase', m.high_chase_thr); set('mom_max_daily_chg', m.max_daily_chg); set('mom_min_price', m.min_price); set('mom_max_loss_krw', m.max_loss_krw); set('mom_min_margin', m.min_margin); set('mom_max_from_open', m.mom_max_from_open_pct); set('mom_min_from_open', m.mom_min_from_open_pct); if ($('mom_pat_breakout') && m.pattern_breakout !== undefined) { $('mom_pat_breakout').checked = !!m.pattern_breakout; } if ($('mom_pat_pullback') && m.pattern_pullback !== undefined) { $('mom_pat_pullback').checked = !!m.pattern_pullback; } set('mom_chase_lookback', m.chase_lookback_min); set('mom_pullback_lookback', m.pullback_lookback_min); set('mom_pullback_min', m.pullback_min_pct); set('mom_pullback_max', m.pullback_max_pct); set('mom_setup_vol', m.setup_vol_max_mult); set('mom_setup_bear', m.setup_bear_bars_min); if (m.use_defense_filters !== undefined && $('mom_use_defense')) { $('mom_use_defense').checked = !!m.use_defense_filters; } if ($('mom_use_high_chase_f') && m.use_high_chase_filter !== undefined) { $('mom_use_high_chase_f').checked = !!m.use_high_chase_filter; } if ($('mom_use_daily_range') && m.use_daily_range_filter !== undefined) { $('mom_use_daily_range').checked = !!m.use_daily_range_filter; } if ($('mom_use_ema_f') && m.use_ema_filter !== undefined) { $('mom_use_ema_f').checked = !!m.use_ema_filter; } if ($('mom_use_rsi_max_f') && m.use_rsi_max_filter !== undefined) { $('mom_use_rsi_max_f').checked = !!m.use_rsi_max_filter; } if (m.ema_fast_period != null) set('mom_ema_fast', m.ema_fast_period); if (m.ema_slow_period != null) set('mom_ema_slow', m.ema_slow_period); // 당일 누적손익 다단 트레일(꼬리와 동일) 현재값·방식·프리셋 복원 if (m.daily_profit_enabled !== undefined && $('mom_daily_profit_enabled')) { $('mom_daily_profit_enabled').checked = !!m.daily_profit_enabled; } if (m.daily_risk_budget_enabled !== undefined && $('mom_daily_risk_budget_enabled')) { $('mom_daily_risk_budget_enabled').checked = !!m.daily_risk_budget_enabled; } if ($('mom_daily_trail_tiers')) { $('mom_daily_trail_tiers').value = (m.daily_trail_tiers != null ? String(m.daily_trail_tiers) : ''); } if (m.daily_profit_mode && $('mom_daily_profit_mode')) { $('mom_daily_profit_mode').value = m.daily_profit_mode; } btFillPresetOptions('mom_daily_trail_preset', m.daily_trail_presets); // 체크값 = 모멘텀 실효값(전용 우선·없으면 글로벌 상속). 밑에 글로벌/실매 출처를 표시. const filterNote = (en, glob, explicit) => { const onoff = v => (v ? 'ON' : 'OFF'); const src = explicit ? '모멘텀 전용 설정값' : `글로벌 상속(전용 미설정)`; // 실매도 동일 규칙(orderbook_filter.orderbook_filter_enabled) → 실매 적용값 = 실효값 return `글로벌 ${onoff(glob)} · 실매 ${onoff(en)} · ${src}`; }; if (m.ob_filter_enabled !== undefined && $('mom_ob_filter')) { $('mom_ob_filter').checked = !!m.ob_filter_enabled; if ($('mom_ob_filter_note')) { $('mom_ob_filter_note').textContent = filterNote( !!m.ob_filter_enabled, !!m.ob_global_enabled, !!m.ob_strategy_explicit, ); } } if (m.pg_filter_enabled !== undefined && $('mom_pg_filter')) { $('mom_pg_filter').checked = !!m.pg_filter_enabled; if ($('mom_pg_filter_note')) { $('mom_pg_filter_note').textContent = filterNote( !!m.pg_filter_enabled, !!m.pg_global_enabled, !!m.pg_strategy_explicit, ); } } if (m.max_spread_pct != null) set('mom_max_spread_pct', m.max_spread_pct); } function loadMomentumSearchResults() { showSpinner(true); fetch('/api/backtest/momentum/search_results?top=30') .then(r => r.json()) .then(d => { showSpinner(false); if (d.error) { alert('❌ ' + d.error); return; } momRenderParamSearch(d.top || [], d.meta || {}); }) .catch(err => { showSpinner(false); alert('오류: ' + err); }); } function momApplySearchResult(rank, saveDb) { showSpinner(true); fetch('/api/backtest/momentum/apply_search', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ rank, save_db: !!saveDb }), }) .then(r => r.json()) .then(d => { showSpinner(false); if (d.error) { alert('❌ ' + d.error); return; } if (d.ui) fillMomentumFormFromApi(d.ui); const m = d.metrics || {}; const msg = saveDb ? `✅ ${rank}위 → 폼 반영 + DB 저장 (env_id: ${d.env_id || '-'})\n손익 ${fmtKrw(m.total_pnl)} · 승률 ${m.win_rate}% · PF ${m.pf} · ${m.total_trades}건` : `✅ ${rank}위 → 폼만 반영 (DB 미저장)\n손익 ${fmtKrw(m.total_pnl)} · 승률 ${m.win_rate}% · PF ${m.pf}`; alert(msg); }) .catch(err => { showSpinner(false); alert('오류: ' + err); }); } function momRenderParamSearch(top, meta) { window._mom_last_top = top || []; const area = $('mom_search_result'); if (area) area.style.display = ''; const labelMap = { mom_rsi_min: 'RSI min', mom_rsi_max: 'RSI max', mom_vol_mult: '거래량×', e_min_chg_pct: '전일종가+%', 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} ${fmtWon(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손익 ${fmtKrw(m.total_pnl)} · 승률 ${m.win_rate}% · PF ${m.pf} · ${m.total_trades}건` : `✅ ${rank}위 → 폼만 반영 (DB 미저장)\n손익 ${fmtKrw(m.total_pnl)} · 승률 ${m.win_rate}% · PF ${m.pf}`; alert(msg); }) .catch(err => { showSpinner(false); alert('오류: ' + err); }); } function tlRenderParamSearch(top, meta) { window._tl_last_top = top || []; $('tl_search_result').style.display = ''; const labelMap = { max_daily_change: '급등컷%', min_drop_rate: '낙폭', min_recovery_ratio: '회복', tail_ratio_min: '꼬리/몸통', max_rec_3m: '3분회복', shoulder_min_high: '어깨발동', shoulder_cut_pct: '어깨컷', stop_atr_mult: 'ATR손절', target_atr_mult: 'ATR익절', limit_atr_mult: '지정가ATR', entry_mode: '진입모드', trail_pct: '보조트레일%', trail_arm_pct: '트레일무장%', }; const fracKeys = new Set(['min_drop_rate', 'min_recovery_ratio', 'shoulder_min_high', 'shoulder_cut_pct']); const ratioKeys = new Set(['max_rec_3m']); const fixedLabel = (k) => labelMap[k] || k; const keys = top && top.length ? Object.keys(top[0].params || {}) : []; const thead = $('tl_search_thead'); thead.innerHTML = '순위' + 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} ${fmtWon(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), symbol_daily_loss_limit_krw: parseFloat($('tl_symbol_loss_krw')?.value || '30000'), symbol_daily_loss_limit_pct: parseFloat($('tl_symbol_loss_pct')?.value || '1.5'), reentry_min_edge_krw: parseFloat($('tl_reentry_min_edge')?.value || '0'), 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', daily_profit_enabled: !!($('tl_daily_profit_enabled')?.checked), daily_risk_budget_enabled: !!($('tl_daily_risk_budget_enabled')?.checked), 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, pattern_hammer: $('tl_pat_hammer')?.checked !== false, pattern_pin: !!$('tl_pat_pin')?.checked, pattern_engulfing: !!$('tl_pat_engulfing')?.checked, pattern_piercing: !!$('tl_pat_piercing')?.checked, pattern_harami: !!$('tl_pat_harami')?.checked, pattern_doji: !!$('tl_pat_doji')?.checked, pattern_morning_star: !!$('tl_pat_morning_star')?.checked, ob_filter: !!($('tl_ob_filter')?.checked), pg_filter: !!($('tl_pg_filter')?.checked), eod_enabled: !!($('tl_eod_enabled')?.checked), eod_hm: ($('tl_eod_hm') && $('tl_eod_hm').value.trim()) || '15:25', }; if (!confirm(`💾 꼬리잡기 봇(config_short TAIL_*)에 저장합니까?\n\n` + `1회투자: ${body.slot_money}원 | 동시보유: ${body.max_stocks}종 | 총운용한도: ${body.total_budget_krw || body.max_stocks * body.slot_money}원 | ` + `낙폭: ${body.min_drop_rate}% | 회복: ${body.min_recovery_ratio}% | 꼬리/몸통: ${body.tail_ratio_min} | 꼬리최소: ${body.tail_pct_min}%\n` + `손절: ${body.sl_pct}% | 익절: ${body.tp_pct}% | 어깨컷: ${body.shoulder_cut_pct}% | 3분최대회복: ${body.max_rec_3m}% | 고점추격방지: ${body.high_chase_thr}%\n` + `최소가격: ${body.min_price}원 | 최대급등: ${body.max_daily_change}% | MA20이격: ${body.ma20_max_above}% | 최대손실: ${body.max_loss_krw}원\n` + `ATR손절: ${body.stop_atr_mult}배 | ATR익절: ${body.target_atr_mult}배 | ` + `캡 SL ${body.atr_sl_min_pct}~${body.atr_sl_max_pct}% TP ${body.atr_tp_min_pct}~${body.atr_tp_max_pct}%\n` + `래칫: ${body.ratchet_tiers || '(OFF·단일어깨)'}\n` + `다단트레일: ${body.daily_profit_enabled ? 'ON' : 'OFF'} | 리스크버짓(B): ${body.daily_risk_budget_enabled ? 'ON' : 'OFF'} | 다단규칙: ${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 isHistoryUniverseSource(src) { return String(src || '').startsWith('history'); } function univHistorySourceParam(selectId) { const el = $(selectId); const v = (el && el.value) ? String(el.value).trim().toLowerCase() : 'kiwoom'; return (v === 'ls') ? 'ls' : 'kiwoom'; } function formatUniverseLabel(p) { const histSrc = isHistoryUniverseSource(p.universe_source); if (histSrc && (p.universe_history_slots || 0) > 0) { const strictTag = (p.universe_timing === 'strict' || String(p.universe_source || '').includes('strict')) ? '·strict' : ''; const lsTag = String(p.universe_source || '').includes('history_ls') ? '·LS' : ''; return `유니버스: 저장 이력 ${p.universe_history_slots}슬롯${lsTag}${strictTag}`; } if (p.universe_source === 'sim') return '유니버스: 시뮬레이션'; if (p.universe_source === 'all') return '유니버스: 전체 ws_candles 종목'; return '유니버스: 시뮬레이션(폴백)'; } /** env 타임라인 쿼리값 — 체크박스 없으면 0(OFF 기본). */ function envTimelineParam(checkboxId) { return $(checkboxId)?.checked ? 1 : 0; } 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, skip_hts_scan_dupes: $('bt_skip_hts_dupes')?.checked ? 1 : 0, require_reversal_candle: $('bt_require_reversal')?.checked ? 1 : 0, max_stocks: $('bt_max_stocks')?.value, total_budget_krw: $('bt_total_budget')?.value, eod_enabled: $('bt_eod_enabled')?.checked ? 1 : 0, eod_hm: ($('bt_eod_hm') && $('bt_eod_hm').value.trim()) || '15:25', env_timeline: envTimelineParam('bt_env_timeline'), }; params.universe = $('bt_use_univ_history')?.checked ? 'history' : 'sim'; params.universe_history_source = univHistorySourceParam('bt_univ_history_source'); params.candle_source = $('bt_candle_source')?.value || ''; params.tick_source = $('bt_tick_source')?.value || ''; params.ob_source = $('bt_ob_source')?.value || ''; 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.eod_enabled ? 'ON' : 'OFF'} ${p.eod_hm || ''} ` + `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 btTick = (d.tick_backtest || d.meta && d.meta.tick_backtest) || {}; const btBuySrc = d.backtest_buy_source || (d.meta && d.meta.backtest_buy_source) || ''; if (btTick.tick_bar_coverage_pct != null || btBuySrc) { const covLabel = fmtTickCoverageLabel(btTick); const rows = btTick.ws_tick_rows_loaded != null ? Number(btTick.ws_tick_rows_loaded).toLocaleString() : '—'; const srcLabel = btBuySrc === 'ws_ticks' ? '틱DB(ws_ticks)' : (btBuySrc === 'ohlc_fallback' ? 'OHLC 폴백' : (btBuySrc || '—')); const tickColor = btBuySrc === 'ws_ticks' ? '#3fb950' : '#e3b341'; $('bt_params_bar').innerHTML += `
📊 매수재생 ${srcLabel} | 틱 ${rows}건 · 분봉커버 ${covLabel}
`; } const ctx = $('bt_trade_context'); if (ctx) { fillTradePnLContext(ctx, { label: '백테 SCALP', totalBudget: Number(p.total_budget_krw || 0), peakCum: Number(s.peak_cum_pnl || 0), peakAt: s.peak_cum_at || '', totalPnl: s.total_pnl, }); } $('b_total').textContent = fmt(s.total_trades) + '건'; $('b_winrate').textContent = s.win_rate + '%'; colorPnl($('b_winrate'), s.win_rate - 50); $('b_pnl').textContent = fmtKrw(s.total_pnl); colorPnl($('b_pnl'), s.total_pnl); $('b_pf').textContent = s.profit_factor >= 999 ? '∞' : s.profit_factor; colorPnl($('b_pf'), s.profit_factor - 1); $('b_mdd').textContent = '-' + fmtWon(s.max_drawdown) + '원'; $('b_hold').textContent = s.avg_hold_min + '분'; const btSign = (v) => (v > 0 ? '+' : ''); if ($('b_bot_pct')) { $('b_bot_pct').textContent = btSign(s.bot_pct || 0) + (s.bot_pct || 0) + '%'; colorPnl($('b_bot_pct'), s.bot_pct || 0); } if ($('b_daily_avg_pct')) { $('b_daily_avg_pct').textContent = btSign(s.daily_avg_pct || 0) + (s.daily_avg_pct || 0) + '%'; colorPnl($('b_daily_avg_pct'), s.daily_avg_pct || 0); } 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, showCumulative: true, totalBudget: Number((d.params && d.params.total_budget_krw) || 0), }); } // ──────────────────────────────────────────── // 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', env_timeline: envTimelineParam('db_env_timeline'), }); 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', fmtKrw(s.total_pnl)); colorPnl($('db_pnl'), s.total_pnl); setTxt('db_pf', (s.profit_factor || 0) >= 999 ? '∞' : String(s.profit_factor || 0)); colorPnl($('db_pf'), (s.profit_factor || 0) - 1); setTxt('db_mdd', '-' + fmtWon(s.max_drawdown) + '원'); setTxt('db_hold', (s.avg_hold_min || 0) + '분'); setTxt('db_bot_pct', sign(s.bot_pct || 0) + (s.bot_pct || 0) + '%'); colorPnl($('db_bot_pct'), s.bot_pct || 0); setTxt('db_daily_avg_pct', sign(s.daily_avg_pct || 0) + (s.daily_avg_pct || 0) + '%'); colorPnl($('db_daily_avg_pct'), s.daily_avg_pct || 0); setTxt('db_candles', d.candle_count != null ? d.candle_count : (p.candle_count || '-')); if (s.budget_warning) { if (dbPb) dbPb.innerHTML += `
💰 ${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) + fmtKrw(s.bnh_pnl)); colorPnl($('db_bnh_pnl'), s.bnh_pnl); setTxt('db_alpha', sign(s.alpha_pct) + (s.alpha_pct != null ? s.alpha_pct : 0) + '%p'); colorPnl($('db_alpha'), s.alpha_pct || 0); } else { const bnhRow = $('db_bnh_row'); if (bnhRow) bnhRow.style.display = 'none'; } try { if (d.equity && d.equity.length) { lineChart('db_equity_chart', d.equity.map(e => e.date), d.equity.map(e => e.cum_pnl), '가상누적손익', '#3fb950'); } else { destroyChart('db_equity_chart'); } const rKeys = Object.keys(d.reasons || {}); if (rKeys.length) { doughnutChart('db_reason_chart', rKeys, rKeys.map(k => d.reasons[k])); } else { destroyChart('db_reason_chart'); } } catch (chartErr) { console.error('db chart render', chartErr); } const tbody = $('db_tbody'); if (tbody) { tbody.innerHTML = ''; trades.forEach(t => { const tr = document.createElement('tr'); const pnl = Number(t.pnl || 0); tr.innerHTML = `${t.side || 'long'}${t.entry_time || ''}${t.exit_time || ''}` + `${fmtWon(t.entry)}${fmtWon(t.exit)}` + `${fmtWon(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}${fmtWon(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, symbol_daily_loss_limit_krw: $('tl_symbol_loss_krw')?.value || '30000', symbol_daily_loss_limit_pct: $('tl_symbol_loss_pct')?.value || '1.5', reentry_min_edge_krw: $('tl_reentry_min_edge')?.value || '0', 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', universe_history_source: univHistorySourceParam('tl_univ_history_source'), candle_source: $('tl_candle_source')?.value || '', tick_source: $('tl_tick_source')?.value || '', ob_source: $('tl_ob_source')?.value || '', 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 ? '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', daily_profit_enabled: $('tl_daily_profit_enabled')?.checked ? 1 : 0, daily_risk_budget_enabled: $('tl_daily_risk_budget_enabled')?.checked ? 1 : 0, eod_enabled: $('tl_eod_enabled')?.checked ? 1 : 0, eod_hm: ($('tl_eod_hm') && $('tl_eod_hm').value.trim()) || '15:25', env_timeline: envTimelineParam('tl_env_timeline'), }; 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); }); } // ════════════════════════════════════════════════════════════════ // 전략 백테 CLI 잡 (꼬리·스캘핑·돌파·모멘텀) — 전역 1잡 · 탭 이동 OK · 좀비방지 // ════════════════════════════════════════════════════════════════ const BTJOB_LS_KEY = 'kis_bt_job_id'; const BTJOB_LS_KEY_LEGACY = 'kis_tail_bt_job_id'; let _btJobPollTimer = null; let _btJobId = localStorage.getItem(BTJOB_LS_KEY) || localStorage.getItem(BTJOB_LS_KEY_LEGACY) || ''; let _btJobRenderedId = ''; let _btJobStrategy = ''; const BTJOB_CFG = { tail: { tab: 'tail', label: '꼬리 백테', startId: 'tl_start', endId: 'tl_end', tfId: 'tl_tf', univId: 'tl_use_univ_history', univSrcId: 'tl_univ_history_source', tickId: 'tl_use_tick_db', envId: 'tl_env_timeline', obId: 'tl_ob_filter', stopBtn: 'tl_btn_stop_bg', tabFill: 'tlbt_tab_fill', tabStatus: 'tlbt_tab_status', clearPrefix: 'tl_', universeOn: 'history', universeOff: 'all', }, scalp: { tab: 'backtest', label: '스캘핑 백테', startId: 'bt_start', endId: 'bt_end', tfId: null, univId: 'bt_use_univ_history', univSrcId: 'bt_univ_history_source', tickId: null, envId: 'bt_env_timeline', obId: null, stopBtn: 'bt_btn_stop_bg', tabFill: 'scalp_bt_tab_fill', tabStatus: 'scalp_bt_tab_status', clearPrefix: 'b_', universeOn: 'history', universeOff: 'sim', }, breakout: { tab: 'breakout', label: '돌파 백테', startId: 'bo_start', endId: 'bo_end', tfId: null, univId: 'bo_use_univ_history', univSrcId: 'bo_univ_history_source', tickId: null, envId: 'bo_env_timeline', obId: null, stopBtn: 'bo_btn_stop_bg', tabFill: 'bo_bt_tab_fill', tabStatus: 'bo_bt_tab_status', clearPrefix: 'bo_', universeOn: 'history', universeOff: 'all', }, momentum: { tab: 'momentum', label: '모멘텀 백테', startId: 'mom_start', endId: 'mom_end', tfId: null, univId: 'mom_use_univ_history', univSrcId: 'mom_univ_history_source', tickId: null, envId: 'mom_env_timeline', obId: 'mom_ob_filter', stopBtn: 'mom_btn_stop_bg', tabFill: 'mom_bt_tab_fill', tabStatus: 'mom_bt_tab_status', clearPrefix: 'mom_', universeOn: 'history', universeOff: 'sim', }, }; function btJobGoTab(strat) { const s = strat || _btJobStrategy || 'tail'; const cfg = BTJOB_CFG[s] || BTJOB_CFG.tail; const el = document.querySelector('[data-tab="' + cfg.tab + '"]'); if (el) el.click(); } function btJobClearUi(strat, msg) { const cfg = BTJOB_CFG[strat] || BTJOB_CFG.tail; const map = { tail: ['tl_total','tl_winrate','tl_pnl','tl_pf','tl_mdd','tl_hold','tl_bot_pct','tl_daily_avg_pct'], scalp: ['b_total','b_winrate','b_pnl','b_pf','b_mdd','b_hold','b_bot_pct','b_daily_avg_pct'], breakout: ['bo_total','bo_winrate','bo_pnl','bo_pf','bo_mdd','bo_hold','bo_bot_pct','bo_daily_avg_pct'], momentum: ['mom_total','mom_winrate','mom_pnl','mom_pf','mom_mdd','mom_hold','mom_bot_pct','mom_daily_avg_pct'], }; const ids = map[strat] || map.tail; ids.forEach((id, i) => { const el = $(id); if (!el) return; if (i === 2) el.textContent = '실행중'; else el.textContent = i < 2 ? '…' : '—'; }); const tbody = strat === 'tail' ? $('tl_tbody') : (strat === 'scalp' ? $('bt_tbody') : (strat === 'breakout' ? $('bo_tbody') : $('mom_tbody'))); if (tbody) { tbody.innerHTML = `${msg || '백그라운드 실행 중…'}`; } if (cfg.tabStatus && $(cfg.tabStatus)) { $(cfg.tabStatus).textContent = msg || '실행중…'; } } function btJobSetNav(job) { const bar = $('btjob_nav_bar'); if (!bar) return; if (!job) { bar.classList.add('d-none'); return; } const strat = job.strategy || _btJobStrategy || 'tail'; _btJobStrategy = strat; const cfg = BTJOB_CFG[strat] || BTJOB_CFG.tail; const prog = job.progress || {}; const pct = Number(prog.pct || 0); const st = job.status || ''; const label = (job.label || cfg.label) + (st === 'running' ? ' 실행중' : (st === 'done' ? ' 완료' : (st === 'error' ? ' 오류' : ''))); const range = (job.start && job.end) ? ` ${job.start}~${job.end}` : ''; if ($('btjob_nav_label')) $('btjob_nav_label').textContent = label + range; if ($('btjob_nav_pct')) $('btjob_nav_pct').textContent = `${pct}%`; const fill = $('btjob_nav_fill'); if (fill) { fill.style.width = `${Math.min(100, pct)}%`; fill.classList.toggle('is-done', st === 'done'); fill.classList.toggle('is-error', st === 'error'); } bar.classList.remove('d-none'); bar.onclick = () => btJobGoTab(strat); const tabFill = cfg.tabFill && $(cfg.tabFill); if (tabFill) { tabFill.style.width = `${Math.min(100, pct)}%`; tabFill.classList.toggle('is-done', st === 'done'); tabFill.classList.toggle('is-error', st === 'error'); } const stEl = cfg.tabStatus && $(cfg.tabStatus); if (stEl) { const msg = prog.message || ''; stEl.innerHTML = `${st} ${pct}%` + range + (msg ? ` · ${msg}` : '') + (job.log_path ? ` · ${job.log_path}` : '') + ' · 전역 1잡 · 탭 이동 OK'; } // 모든 전략 중지 버튼 동기화 Object.values(BTJOB_CFG).forEach(c => { const b = c.stopBtn && $(c.stopBtn); if (b) b.disabled = st !== 'running'; }); } async function btJobLoadResult(jobId, strategy) { const rr = await fetch('/api/backtest/job/result?job_id=' + encodeURIComponent(jobId)); const rd = await rr.json(); if (!rd.ok) throw new Error(rd.error || '결과 없음'); const strat = strategy || rd.strategy || 'tail'; const payload = rd.data || rd.raw || rd; // 과거 CLI 버그: 핸들러 500을 ok:true + error 로 저장 → 새로고침마다 alert 재발 const failMsg = (payload && payload.error) || rd.error || ''; if (failMsg) { const cfg = BTJOB_CFG[strat] || BTJOB_CFG.tail; const stEl = cfg.tabStatus && $(cfg.tabStatus); if (stEl) { stEl.textContent = '실패(구잡): ' + String(failMsg).slice(0, 120) + ' · 다시 실행하세요'; } _btJobId = ''; localStorage.removeItem(BTJOB_LS_KEY); localStorage.removeItem(BTJOB_LS_KEY_LEGACY); btJobSetNav(null); throw new Error(String(failMsg)); } if (strat === 'scalp') { renderBacktest(payload); } else if (strat === 'breakout') { renderBreakoutBacktest(payload); } else if (strat === 'momentum') { renderMomentumBacktest(payload); } else { if (payload && payload.equity) renderTailBacktest(payload); else if (rd.ok) renderTailBacktestFromCli(rd); } } async function btJobPollOnce() { const requestedId = _btJobId; if (!requestedId) return; try { const r = await fetch('/api/backtest/job/status?job_id=' + encodeURIComponent(requestedId)); if (requestedId !== _btJobId) return; const j = await r.json(); if (!j.ok) { if (r.status === 404 && requestedId === _btJobId) { _btJobId = ''; localStorage.removeItem(BTJOB_LS_KEY); localStorage.removeItem(BTJOB_LS_KEY_LEGACY); btJobSetNav(null); } return; } const job = j.job || {}; const jid = String(job.job_id || requestedId); if (jid !== _btJobId) return; btJobSetNav(job); const st = job.status; if (st === 'done' && _btJobRenderedId !== jid) { try { await btJobLoadResult(jid, job.strategy); if (jid !== _btJobId) return; _btJobRenderedId = jid; try { btJobGoTab(job.strategy); } catch (e2) {} } catch (e) { console.warn('btJobLoadResult', e); } } } catch (e) { /* ignore */ } } function btJobStartPolling() { if (_btJobPollTimer) clearInterval(_btJobPollTimer); btJobPollOnce(); _btJobPollTimer = setInterval(btJobPollOnce, 2000); } function btJobCollectBody(strategy) { const cfg = BTJOB_CFG[strategy]; if (!cfg) throw new Error('알 수 없는 전략: ' + strategy); const univOn = !!(cfg.univId && $(cfg.univId)?.checked); const body = { strategy, start: $(cfg.startId)?.value, end: $(cfg.endId)?.value, universe: univOn ? cfg.universeOn : cfg.universeOff, universe_history_source: cfg.univSrcId ? univHistorySourceParam(cfg.univSrcId) : 'kiwoom', orderbook_filter: (cfg.obId && $(cfg.obId)?.checked) ? 'on' : 'off', env_timeline: cfg.envId ? envTimelineParam(cfg.envId) : 0, }; if (cfg.tfId && $(cfg.tfId)) body.timeframe = $(cfg.tfId).value || 3; if (cfg.tickId) body.backtest_use_tick_db = $(cfg.tickId)?.checked !== false ? 1 : 0; return body; } async function btJobStart(strategy) { const body = btJobCollectBody(strategy); if (!body.start || !body.end) { alert('시작·종료일을 입력하세요'); return; } if (!confirm( `${BTJOB_CFG[strategy].label} 백그라운드 시작?\n${body.start}~${body.end}\n` + `파라미터=현재 DB (+기간/유니버스)\n다른 전략 백테와 동시 실행 불가(전역 1잡).\n탭 이동 OK.` )) return; try { const r = await fetch('/api/backtest/job/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const j = await r.json(); if (!j.ok) { alert('❌ ' + (j.error || '시작 실패')); return; } _btJobId = j.job.job_id; _btJobStrategy = strategy; _btJobRenderedId = ''; localStorage.setItem(BTJOB_LS_KEY, _btJobId); localStorage.removeItem(BTJOB_LS_KEY_LEGACY); btJobClearUi(strategy, `실행중 ${body.start}~${body.end}`); btJobSetNav(j.job); btJobStartPolling(); } catch (e) { alert('오류: ' + e); } } async function btJobStop() { if (!_btJobId) return; if (!confirm('백그라운드 백테를 중지할까요? (프로세스 정리)')) return; try { const r = await fetch('/api/backtest/job/stop', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ job_id: _btJobId }), }); const j = await r.json(); if (!j.ok) { alert(j.error || '중지 실패'); return; } btJobSetNav(j.job); } catch (e) { alert('오류: ' + e); } } // 하위호환 function runTailBacktestJob() { return btJobStart('tail'); } function tlBtStop() { return btJobStop(); } function tlBtGoTab() { return btJobGoTab('tail'); } function renderTailBacktestFromCli(rd) { // CLI JSON → 웹 /api/backtest/tail 과 동일 스키마 (equity/daily/reasons/win_trades) // 없으면 renderTailBacktest 가 equity.map 에서 터져 승패·차트·거래내역이 안 나옴 const summary = rd.summary || (rd.data && rd.data.summary) || {}; const trades = rd.trades || (rd.data && rd.data.trades) || []; const meta = rd.meta || (rd.data && rd.data.meta) || {}; const pu = rd.params_used || (rd.data && rd.data.params_used) || {}; const sorted = trades.slice().sort((a, b) => String(a.exit_time || a.sell_time || '').localeCompare(String(b.exit_time || b.sell_time || '')) ); let cum = 0; let peak = 0; let peakAt = ''; let mdd = 0; const equity = []; const dailyMap = {}; const reasons = {}; for (const t of sorted) { const pnl = Number(t.pnl ?? t.realized_pnl ?? 0) || 0; cum += pnl; if (cum > peak) { peak = cum; peakAt = String(t.exit_time || t.sell_time || ''); } const dd = peak - cum; if (dd > mdd) mdd = dd; const et = String(t.exit_time || t.sell_time || '').replace(/[-: T]/g, ''); const day = et.length >= 8 ? `${et.slice(0, 4)}-${et.slice(4, 6)}-${et.slice(6, 8)}` : (et || '—'); equity.push({ date: day, cum_pnl: Math.round(cum), pnl: Math.round(pnl) }); dailyMap[day] = (dailyMap[day] || 0) + pnl; const rk = String(t.reason || t.sell_reason || '기타'); reasons[rk] = (reasons[rk] || 0) + 1; } const daily = Object.keys(dailyMap).sort().map(d => ({ date: d, pnl: Math.round(dailyMap[d]), })); const winsN = Number( summary.win_trades != null ? summary.win_trades : (summary.wins != null ? summary.wins : trades.filter(t => Number(t.pnl ?? t.realized_pnl ?? 0) > 0).length) ); const lossN = Number( summary.loss_trades != null ? summary.loss_trades : (summary.losses != null ? summary.losses : Math.max(0, trades.length - winsN)) ); const fake = { summary: { total_trades: summary.total_trades != null ? summary.total_trades : trades.length, win_trades: winsN, loss_trades: lossN, win_rate: summary.win_rate || 0, total_pnl: summary.total_pnl || 0, profit_factor: summary.profit_factor != null ? summary.profit_factor : (summary.pf || 0), max_drawdown: summary.max_drawdown != null ? summary.max_drawdown : (summary.mdd != null ? summary.mdd : Math.round(mdd)), avg_hold_min: summary.avg_hold_min || 0, bot_pct: summary.bot_pct || 0, daily_avg_pct: summary.daily_avg_pct || 0, peak_cum_pnl: summary.peak_cum_pnl != null ? summary.peak_cum_pnl : Math.round(peak), peak_cum_at: summary.peak_cum_at || peakAt, tick_backtest: summary.tick_backtest || meta.tick_backtest, backtest_buy_source: summary.backtest_buy_source || meta.backtest_buy_source, tick_entry_sources: summary.tick_entry_sources || meta.tick_entry_sources, skip_stats: summary.skip_stats || meta.skip_stats || {}, }, params: { universe_source: meta.universe_source || rd.universe_source || 'history', timeframe: rd.timeframe || pu.timeframe || 3, slot_money: pu.slot_money || 0, max_stocks: pu.max_stocks || 0, total_budget_krw: pu.total_budget_krw || 0, codes_analyzed: meta.codes_analyzed || meta.n_codes || (Array.isArray(meta.codes) ? meta.codes.length : (trades.length || 0)), min_drop_rate: (function () { const v = Number(pu.min_drop_rate); if (!Number.isFinite(v)) return pu.min_drop_rate ?? ''; // 엔진 비율(0.055) → UI % (5.5) return (v > 0 && v <= 1) ? (v * 100) : v; })(), min_recovery_ratio: (function () { const v = Number(pu.min_recovery_ratio); if (!Number.isFinite(v)) return pu.min_recovery_ratio ?? ''; return (v > 0 && v <= 1) ? (v * 100) : v; })(), tail_ratio_min: pu.tail_ratio_min ?? '', sl_pct: (function () { const v = Number(pu.sl_pct); if (!Number.isFinite(v)) return pu.sl_pct ?? ''; return (v > 0 && v <= 1) ? (v * 100) : v; })(), tp_pct: (function () { const v = Number(pu.tp_pct); if (!Number.isFinite(v)) return pu.tp_pct ?? ''; return (v > 0 && v <= 1) ? (v * 100) : v; })(), shoulder_min_high: (function () { const v = Number(pu.shoulder_min_high ?? pu.shoulder_min_high_pct); if (!Number.isFinite(v)) return ''; return (v > 0 && v <= 1) ? (v * 100) : v; })(), shoulder_cut_pct: (function () { const v = Number(pu.shoulder_cut_pct); if (!Number.isFinite(v)) return ''; return (v > 0 && v <= 1) ? (v * 100) : v; })(), trail_pct: pu.trail_pct ?? 0, trail_arm_pct: pu.trail_arm_pct ?? 0, max_loss_krw: pu.max_loss_krw, rsi_period: pu.rsi_period ?? '', rsi_threshold: pu.rsi_threshold ?? '', cooldown_min: pu.cooldown_min ?? '', time_window: pu.time_window || ( (pu.time_start != null && pu.time_end != null) ? `${pu.time_start}-${pu.time_end}` : '' ), max_daily: pu.max_daily ?? '', entry_mode: pu.entry_mode, limit_atr_mult: pu.limit_atr_mult, limit_anchor: pu.limit_anchor, limit_valid_bars: pu.limit_valid_bars, limit_fill_slip_pct: pu.limit_fill_slip_pct, }, equity, daily, reasons, trades, }; try { renderTailBacktest(fake); } catch (e) { console.warn('renderTailBacktestFromCli', e); alert( `백그라운드 완료(화면 렌더 실패)\n거래 ${summary.total_trades} · WR ${summary.win_rate}% · PnL ${summary.total_pnl}\n` + (rd.result_json || '') + `\n${e}` ); } } // 페이지 로드 시 이전 잡 폴링 재개 (탭 무관) if (_btJobId) { setTimeout(() => btJobStartPolling(), 800); } 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) { fillTradePnLContext(tlCtx, { label: '백테 SHORT(꼬리)', totalBudget: Number(p.total_budget_krw || 0), peakCum: Number(s.peak_cum_pnl || 0), peakAt: s.peak_cum_at || '', totalPnl: s.total_pnl, }); } $('tl_total').textContent = (s.total_trades||0) + '건'; $('tl_winrate').textContent = (s.win_rate||0) + '%'; colorPnl($('tl_winrate'), (s.win_rate||0) - 50); $('tl_pnl').textContent = fmtKrw(s.total_pnl); colorPnl($('tl_pnl'), s.total_pnl); $('tl_pf').textContent = ((s.profit_factor ?? s.pf) || 0) >= 999 ? '∞' : ((s.profit_factor ?? s.pf) || 0); colorPnl($('tl_pf'), ((s.profit_factor ?? s.pf) || 0) - 1); $('tl_mdd').textContent = '-' + fmtWon(s.max_drawdown ?? s.mdd ?? 0) + '원'; $('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 covLabel = fmtTickCoverageLabel(tickMeta); 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}건 · 분봉커버 ${covLabel}
`; $('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})` + ` · 최종 ${fmtKrw(s.total_pnl)}`; $('tl_params_bar').innerHTML += `
${peakLine}
`; } if ((s.total_trades||0) > 0) { $('tl_winbar_card').style.display = ''; const wr = s.win_rate||0; const winN = s.win_trades != null ? s.win_trades : (s.wins || 0); const lossN = s.loss_trades != null ? s.loss_trades : (s.losses || 0); $('tl_win_label').textContent = `🟢 승 ${winN}건 (${wr}%)`; $('tl_loss_label').textContent = `🔴 패 ${lossN}건 (${(100-wr).toFixed(1)}%)`; $('tl_ratio_g').style.width = wr + '%'; $('tl_ratio_r').style.width = (100 - wr) + '%'; } // 누적 손익 곡선 (백그라운드 CLI 결과는 equity 없을 수 있음 → 빈 배열 허용) const tlEquity = Array.isArray(d.equity) ? d.equity : []; lineChart('tl_equity_chart', tlEquity.map(e => e.date), tlEquity.map(e => e.cum_pnl), '가상누적손익', '#3fb950'); // 매도 이유 도넛 const tlReasons = d.reasons || {}; const rKeys = Object.keys(tlReasons); doughnutChart('tl_reason_chart', rKeys, rKeys.map(k => tlReasons[k])); // 일별 바 const tlDaily = Array.isArray(d.daily) ? d.daily : []; barChart('tl_daily_chart', tlDaily.map(e => String(e.date || '').slice(5)), tlDaily.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 = fmtKrw(s.total_pnl); colorPnl($('hd_pnl'), s.total_pnl); $('hd_pf').textContent = (s.profit_factor||0) >= 999 ? '∞' : (s.profit_factor||0); colorPnl($('hd_pf'), (s.profit_factor||0) - 1); $('hd_mdd').textContent = '-' + fmtWon(s.max_drawdown) + '원'; $('hd_hold').textContent = (s.avg_hold_days||0) + '일'; const sign = v => (v > 0 ? '+' : ''); if ($('hd_bot_pct_sum')) { $('hd_bot_pct_sum').textContent = sign(s.bot_pct || 0) + (s.bot_pct || 0) + '%'; colorPnl($('hd_bot_pct_sum'), s.bot_pct || 0); } if ($('hd_daily_avg_pct')) { let dAvg = s.daily_avg_pct; if (dAvg == null && s.bot_pct != null) { // 홀딩은 일봉 — 보유일 평균이 있으면 그걸로, 없으면 bot_pct 그대로 표시용 const holdD = Number(s.avg_hold_days || 0); dAvg = holdD > 0 ? Math.round((Number(s.bot_pct) / Math.max(1, holdD)) * 1000) / 1000 : s.bot_pct; } $('hd_daily_avg_pct').textContent = (dAvg == null) ? '-' : (sign(dAvg) + dAvg + '%'); if (dAvg != null) colorPnl($('hd_daily_avg_pct'), dAvg); } // Buy & Hold 비교 (전체 구간) if (s.bnh_pct !== undefined && s.bnh_pct !== null) { $('hd_bnh_row').style.display = ''; $('hd_bot_pct').textContent = sign(s.bot_pct||0) + (s.bot_pct||0) + '%'; colorPnl($('hd_bot_pct'), s.bot_pct||0); $('hd_bnh_pct').textContent = sign(s.bnh_pct) + s.bnh_pct + '%'; colorPnl($('hd_bnh_pct'), s.bnh_pct); $('hd_bnh_pnl').textContent = sign(s.bnh_pnl) + fmtKrw(s.bnh_pnl); colorPnl($('hd_bnh_pnl'), s.bnh_pnl); $('hd_alpha').textContent = sign(s.alpha_pct) + (s.alpha_pct != null ? s.alpha_pct : 0) + '%p'; colorPnl($('hd_alpha'), s.alpha_pct||0); } else { $('hd_bnh_row').style.display = 'none'; } const alRow = $('hd_bnh_aligned_row'); if (alRow) { if (s.bnh_aligned_pct !== undefined && s.bnh_aligned_pct !== null) { alRow.style.display = ''; $('hd_bnh_aligned_pct').textContent = sign(s.bnh_aligned_pct) + s.bnh_aligned_pct + '%'; colorPnl($('hd_bnh_aligned_pct'), s.bnh_aligned_pct); $('hd_bnh_aligned_pnl').textContent = sign(s.bnh_aligned_pnl) + fmtKrw(s.bnh_aligned_pnl); colorPnl($('hd_bnh_aligned_pnl'), s.bnh_aligned_pnl); const aal = s.alpha_aligned_pct != null ? s.alpha_aligned_pct : 0; $('hd_alpha_aligned').textContent = sign(aal) + aal + '%p'; colorPnl($('hd_alpha_aligned'), aal); } else { alRow.style.display = 'none'; } } const cBlk = $('hd_candle_block'); const cTb = $('hd_candle_tbody'); if (cBlk && cTb) { const rows = d.candle_rows || []; if (rows.length) { cBlk.style.display = ''; cTb.innerHTML = ''; rows.forEach(r => { const up = '#f85149', dn = '#58a6ff', fl = 'var(--muted)'; const cWon = r.chg_won, cPct = r.chg_pct; const colW = r.dir > 0 ? up : (r.dir < 0 ? dn : fl); const colP = colW; const wStr = (cWon == null) ? '—' : ((cWon > 0 ? '+' : '') + fmt(cWon)); const pStr = (cPct == null) ? '—' : ((cPct > 0 ? '+' : '') + Number(cPct).toFixed(2) + '%'); cTb.insertAdjacentHTML('beforeend', ` ${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 || '' }, showCumulative: true, totalBudget: Number((d.params && (d.params.total_budget_krw || d.params.slot_money)) || 0) || 1000000, }); const hdBudget = Number((d.params && (d.params.total_budget_krw || d.params.slot_money)) || 0) || 1000000; let hdPeak = Number(s.peak_cum_pnl || 0); let hdPeakAt = s.peak_cum_at || ''; if (!(hdPeak > 0)) { let cum = 0; const ordered = [...(d.trades || [])].sort((a, b) => tradeExitSortKey(a).localeCompare(tradeExitSortKey(b))); for (const t of ordered) { cum += Number(t.pnl ?? t.realized_pnl ?? 0); if (cum > hdPeak) { hdPeak = cum; hdPeakAt = t.sell_time || t.exit_time || t.sell_date || ''; } } } fillTradePnLContext('hd_trade_context', { label: '백테 HOLDING', totalBudget: hdBudget, peakCum: hdPeak, peakAt: hdPeakAt, totalPnl: s.total_pnl, }); $('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} ${fmtWon(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) { el.value = kstTradingDayIso(_krHolidays); } else { el.value = kstClampToPrevTradingDayIso(el.value, _krHolidays); } } 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)}` : ''; const isDanger = !!(f.danger) || /FALLBACK_OHLC/i.test(String(f.key || '')); const wrapCls = isDanger ? 'lc-field lc-field-danger' : 'lc-field'; 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}
`; } if (f.type === 'select' && Array.isArray(f.choices)) { const val = f.value != null ? String(f.value) : ''; const opts = f.choices.map(c => { const cv = (typeof c === 'object') ? String(c.value) : String(c); const cl = (typeof c === 'object') ? String(c.label != null ? c.label : c.value) : String(c); const sel = (cv === val) ? ' selected' : ''; return ``; }).join(''); return `
${tbl} ${hint}
`; } const step = f.type === 'int' ? '1' : '0.01'; const val = f.value != null ? f.value : ''; return `
${tbl} ${hint}
`; } /** env 키 → 전략 구역 ID (카테고리 안 시각 구분용) */ function lcInferStrategy(key) { const k = String(key || '').toUpperCase(); const m = k.match(/^STRATEGY_([A-Z0-9_]+)_ENABLED$/); if (m) return m[1]; // 긴 prefix 우선 const pairs = [ ['RANGE_BREAK_', 'RANGE_BREAK'], ['UPDOWN_', 'UPDOW'], ['UPDOW_', 'UPDOW'], ['BREAKOUT_', 'BREAKOUT'], ['MOMENTUM_', 'MOMENTUM'], ['SCALP_', 'SCALP'], ['DBBAND_', 'DBBAND'], ['TAIL_', 'SHORT'], ['SHORT_', 'SHORT'], ]; for (let i = 0; i < pairs.length; i++) { if (k.startsWith(pairs[i][0])) return pairs[i][1]; } return 'COMMON'; } /** 공통 섹션용 — 주제(호가/프로그램/휩쏘…)로 한 번 더 묶기 */ function lcInferTopic(key) { const k = String(key || '').toUpperCase(); if (k.includes('ORDERBOOK') || k.includes('SPREAD')) return 'orderbook'; if (k.includes('PROGRAM')) return 'program'; if (k.includes('WHIPSAW')) return 'whipsaw'; if (k.startsWith('WS_TRIGGER') || k.includes('TRIGGER_EVAL')) return 'trigger_master'; if (k.includes('PENDING') || k.includes('FILL') || k.includes('ORDER_') || k.includes('IOC') || k.includes('DEDUP') || k.includes('SLIP') || k.includes('STRICT_FILL') || k.includes('CANCEL_PARTIAL') || k.includes('DUPLICATE_ORDER')) return 'fill'; if (k.includes('BACKTEST') || k.includes('PARAM_SEARCH') || k.includes('POLL_MS') || k.includes('TICK_')) return 'backtest'; if (k.startsWith('KIS_') || k.includes('BALANCE_MAX') || k.includes('INTERVAL_SEC')) return 'rest'; if (k.startsWith('SCAN_') || k.startsWith('STRATEGY_LOOP')) return 'scan'; if (k.includes('ORPHAN') || k.includes('GHOST') || k.includes('MANUAL_HOLD') || k.includes('BULK_SELL') || k.includes('DRIFT')) return 'portfolio'; if (k.includes('DAILY_PROFIT') || k.includes('DAILY_STOP') || k.includes('CONSECUTIVE_LOSS') || k.includes('USE_RISK')) return 'risk'; return 'misc'; } const LC_TOPIC_ORDER = [ 'trigger_master', 'orderbook', 'program', 'whipsaw', 'fill', 'backtest', 'rest', 'scan', 'risk', 'portfolio', 'misc', ]; const LC_TOPIC_LABELS = { trigger_master: 'TRIGGER 마스터', orderbook: '호가', program: '프로그램', whipsaw: '휩쏘', fill: '체결·주문', backtest: '백테·틱청산', rest: 'REST 유량', scan: '스캔 루프', risk: '리스크', portfolio: '보유·고아', misc: '기타', }; const LC_STRAT_ORDER = [ 'COMMON', 'SHORT', 'MOMENTUM', 'BREAKOUT', 'UPDOW', 'SCALP', 'RANGE_BREAK', 'DBBAND', 'OTHER', ]; const LC_STRAT_LABELS = { COMMON: '공통', SHORT: '꼬리잡기', MOMENTUM: '모멘텀', BREAKOUT: '돌파', UPDOW: 'UPDOWN 박스', SCALP: '스캘핑', RANGE_BREAK: '박스권돌파', DBBAND: '더블BB', OTHER: '기타', }; /** 한 카테고리 fields → 전략별 구역 배열 */ function lcPartitionFieldsByStrategy(fields) { const buckets = {}; (fields || []).forEach(f => { let sid = lcInferStrategy(f.key); if (LC_STRAT_ORDER.indexOf(sid) < 0) sid = 'OTHER'; if (!buckets[sid]) buckets[sid] = []; buckets[sid].push(f); }); return LC_STRAT_ORDER .filter(sid => buckets[sid] && buckets[sid].length) .map(sid => ({ sid, label: LC_STRAT_LABELS[sid] || sid, fields: buckets[sid], })); } /** 공통 필드만 있을 때 주제별로 재분할 (2구역 이상이면 사용) */ function lcPartitionFieldsByTopic(fields) { const buckets = {}; (fields || []).forEach(f => { const tid = lcInferTopic(f.key); if (!buckets[tid]) buckets[tid] = []; buckets[tid].push(f); }); return LC_TOPIC_ORDER .filter(tid => buckets[tid] && buckets[tid].length) .map(tid => ({ sid: 'topic', topicId: tid, label: LC_TOPIC_LABELS[tid] || tid, fields: buckets[tid], })); } function lcRenderFieldGrid(fields) { let html = '
'; (fields || []).forEach(f => { html += lcFieldInput(f); }); html += '
'; return html; } function lcRenderBlock(p) { const cls = p.sid === 'topic' ? 'lc-strat-block lc-strat-topic' : ('lc-strat-block lc-strat-' + String(p.sid).toLowerCase()); const sidTag = (p.sid === 'COMMON' || p.sid === 'topic') ? '' : `${lcEsc(p.sid)}`; return `
${lcEsc(p.label)}${sidTag}
${lcRenderFieldGrid(p.fields)}
`; } function lcRenderGroups(groups) { const root = $('lc_groups'); if (!root) return; let html = ''; (groups || []).forEach(g => { const parts = lcPartitionFieldsByStrategy(g.fields || []); const splitStrat = parts.length > 1; // 전략이 하나뿐(공통만)이면 주제로 한 번 더 나눔 let renderParts = parts; let useBlocks = splitStrat; if (!splitStrat && parts.length === 1) { const topics = lcPartitionFieldsByTopic(parts[0].fields); if (topics.length > 1) { renderParts = topics; useBlocks = true; } } html += `
${lcEsc(g.title)}
${g.hint ? `
${lcEsc(g.hint)}
` : ''}
`; if (useBlocks) { html += '
'; renderParts.forEach(p => { html += lcRenderBlock(p); }); html += '
'; } else { html += lcRenderFieldGrid((renderParts[0] && renderParts[0].fields) || g.fields || []); } 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() { // 날짜 기본값 — 주말/휴장이면 이전 장운영일 const end = $('ubx_end'), start = $('ubx_start'); if (end && !end.value) end.value = kstClampToPrevTradingDayIso(kstTodayParts().iso, _krHolidays); else if (end && end.value) end.value = kstClampToPrevTradingDayIso(end.value, _krHolidays); if (start && !start.value) { const endIso = (end && end.value) ? end.value : kstClampToPrevTradingDayIso(kstTodayParts().iso, _krHolidays); start.value = kstClampToPrevTradingDayIso(kstAddDaysIso(endIso, -180), _krHolidays); } else if (start && start.value) { start.value = kstClampToPrevTradingDayIso(start.value, _krHolidays); } 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, }; }); // peak / 최종 누적 (매도순) let peak = 0, peakAt = '', cum = 0; const ordered = [...mapped].sort((a, b) => tradeExitSortKey(a).localeCompare(tradeExitSortKey(b))); for (const t of ordered) { cum += Number(t.pnl || 0); if (cum > peak) { peak = cum; peakAt = t.exit_time || ''; } } renderVirtualTrades('ubx_trades_tbody', mapped, { meta, showCumulative: true, totalBudget: 1000000, }); fillTradePnLContext('ubx_trade_context', { label: '백테 UPDOWN_BOX', totalBudget: 1000000, peakCum: peak, peakAt, totalPnl: cum, }); 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); } // ════════════════════════════════════════════════════════════════ // Optuna 웹 잡 (상단 미니바 + Optuna 탭) // ════════════════════════════════════════════════════════════════ const OPTUNA_LS_KEY = 'kis_optuna_job_id'; let _optunaPollTimer = null; let _optunaJobId = localStorage.getItem(OPTUNA_LS_KEY) || ''; function optunaGoTab() { const el = document.querySelector('[data-tab="optuna"]'); if (el) el.click(); } function optunaOnTabShow() { optunaLoadDefaults(); optunaRefreshJobs(); if (_optunaJobId) optunaPollOnce(); } async function optunaLoadDefaults() { try { const r = await fetch('/api/optuna/defaults'); const j = await r.json(); if (!j.ok) return; if ($('opt_start') && !$('opt_start').value) $('opt_start').value = j.start; if ($('opt_end') && !$('opt_end').value) $('opt_end').value = j.end; if ($('opt_trials') && !$('opt_trials').dataset.touched) $('opt_trials').value = j.trials; } catch (e) { /* ignore */ } } function optunaSetNav(job) { const bar = $('optuna_nav_bar'); if (!bar) return; if (!job) { bar.classList.add('d-none'); return; } const prog = job.progress || {}; const pct = Number(prog.pct || 0); const st = job.status || ''; const label = (job.label || job.strategy || 'Optuna') + (st === 'running' ? ' 실행중' : (st === 'done' ? ' 완료' : (st === 'error' ? ' 오류' : ''))); const done = prog.trials_done != null ? prog.trials_done : '?'; const tot = prog.trials_total != null ? prog.trials_total : '?'; $('optuna_nav_label').textContent = `${label} ${done}/${tot}`; const met = optunaBestMetricsText(prog, job); $('optuna_nav_pct').textContent = met ? `${pct}% · ${met}` : `${pct}%`; const fill = $('optuna_nav_fill'); fill.style.width = `${Math.min(100, pct)}%`; fill.classList.toggle('is-done', st === 'done'); fill.classList.toggle('is-error', st === 'error'); bar.classList.remove('d-none'); } function optunaFmtNum(v, dig) { if (v == null || v === '') return '—'; const n = Number(v); if (!Number.isFinite(n)) return String(v); if (dig == null) return n.toLocaleString(); return n.toFixed(dig); } /** progress.best_* → 사람이 읽는 WR/PnL/PF/MDD (score 숨김) */ function optunaBestMetricsText(prog, job) { if (!prog || prog.best_pnl == null && prog.best_win_rate == null) return ''; const strat = String((job && (job.strategy || job.label)) || '').toLowerCase(); const isUs = !!((job && job.symbol) || strat.includes('us_momentum') || strat.includes('해외')); const pnl = Number(prog.best_pnl); const pnlStr = !Number.isFinite(pnl) ? '—' : (isUs ? `$${pnl.toLocaleString(undefined, { maximumFractionDigits: 2 })}` : `${Math.round(pnl).toLocaleString()}원`); const wr = prog.best_win_rate != null ? `${Number(prog.best_win_rate).toFixed(1)}%` : '—'; const pf = prog.best_pf != null ? Number(prog.best_pf).toFixed(2) : '—'; const mdd = prog.best_mdd != null ? (isUs ? `$${Number(prog.best_mdd).toLocaleString(undefined, { maximumFractionDigits: 2 })}` : `${Math.round(Number(prog.best_mdd)).toLocaleString()}`) : '—'; const tr = prog.best_trades != null ? `${prog.best_trades}건` : ''; const tn = prog.best_trial != null ? `#${prog.best_trial}` : ''; return `best${tn} WR ${wr} · PnL ${pnlStr} · PF ${pf} · MDD ${mdd}` + (tr ? ` · ${tr}` : ''); } function optunaRenderCompare(sum) { const tb = $('opt_compare_tbody'); const vsEl = $('opt_vs_best'); const gatesEl = $('opt_stable_gates'); if (!tb) return; const rows = (sum && sum.compare_rows) || []; if (!rows.length) { tb.innerHTML = '완료 후 표시'; if (vsEl) vsEl.textContent = '—'; if (gatesEl) gatesEl.textContent = '—'; optunaRenderTrailRec(null); optunaRenderPostprocess(null); return; } tb.innerHTML = rows.map(r => ` ${r.label || r.source} ${r.optuna_trial_number != null ? '#' + r.optuna_trial_number : '—'} ${optunaFmtNum(r.total_trades, 0)} ${optunaFmtNum(r.win_rate, 1)} ${optunaFmtNum(r.pf, 2)} ${optunaFmtNum(r.total_pnl)} ${r.n_losing_days != null ? optunaFmtNum(r.n_losing_days, 0) : '—'} ${r.worst_day_pnl != null ? optunaFmtNum(r.worst_day_pnl) : '—'} ${r.stability_score != null ? optunaFmtNum(r.stability_score, 2) : '—'} `).join(''); const vs = sum && sum.vs_best; if (vsEl) { if (vs) { vsEl.innerHTML = `mode vs 학습1위: Δpnl ${optunaFmtNum(vs.delta_pnl)}` + ` · Δwr ${optunaFmtNum(vs.delta_wr, 1)}%p · Δpf ${optunaFmtNum(vs.delta_pf, 2)}` + ` (best ${optunaFmtNum(vs.best_pnl)} / mode ${optunaFmtNum(vs.mode_pnl)})`; } else { vsEl.textContent = 'vs_best 없음 (mode 실측 전/실패 가능)'; } } if (gatesEl) { const g = sum && sum.stable_gates; if (g) { gatesEl.textContent = `stable 게이트: 손실일≤${g.max_losing_days} · 최악일≥${Number(g.min_worst_day_pnl).toLocaleString()} · ` + `활성일≥${g.min_active_days} · λ=${g.stability_lambda}` + (g.score_note ? ` · ${g.score_note}` : ''); } else { gatesEl.textContent = 'stable 게이트: 구 JSON이거나 미기록 (Optuna 재실행 시 생성)'; } } optunaRenderTrailRec(sum); optunaRenderPostprocess(sum); } function optunaRenderTrailRec(sum) { const body = $('opt_daily_trail_rec_body'); if (!body) return; const rec = sum && sum.daily_trail_recommend; if (!rec) { body.textContent = '완료 후 표시'; return; } if (!rec.ok) { body.innerHTML = `추천 없음 — ${rec.reason || '양수 PnL 앵커 없음'}`; return; } const arm = Number(rec.arm_krw || 0).toLocaleString(); const anchor = Number(rec.anchor_krw || 0).toLocaleString(); body.innerHTML = `${rec.prefix || ''} · ENABLED=true · MODE=${rec.mode || 'trailing'}` + `
ARM ${arm}원 · TIERS ${rec.tiers || ''}` + `
앵커 ${anchor}원 (mode/median/best×frac) · apply 시에만 DB 기록 · 이후 운영탭에서 끄기/수정 가능`; } /** Optuna 후처리 3종 표 — 호가(진입+EXIT익절) · 휩쏘 · 트레일. STOP_OB 합의는 미구현. */ function optunaRenderPostprocess(sum) { const tb = $('opt_postprocess_tbody'); if (!tb) return; if (!sum) { tb.innerHTML = '완료 후 표시'; return; } const rows = []; const ob = sum.orderbook_recommend; if (!ob) { rows.push({ kind: '호가 (진입+익절)', st: '미포함', nums: '—', stats: '—', note: '구 JSON / 미생성', }); } else if (!ob.ok) { rows.push({ kind: '호가 (진입+익절)', st: '실패', nums: '—', stats: '—', note: ob.reason || 'n/a', }); } else { const p = ob.params || {}; const rs = ob.recommended_stats || {}; const exitOn = p.exit_ob_enabled ? 'ON' : 'OFF'; rows.push({ kind: '호가 (진입+익절)', st: 'ok', nums: `스프레드≤${p.orderbook_max_spread_pct != null ? p.orderbook_max_spread_pct : '—'}%` + ` · 잔량≥${p.orderbook_min_bid_ask_ratio != null ? p.orderbook_min_bid_ask_ratio : '—'}` + ` · EXIT_OB=${exitOn}` + (p.exit_ob_enabled ? ` (OR≥${p.exit_ob_ratio_min != null ? p.exit_ob_ratio_min : '—'})` : ''), stats: `모수 ${ob.trade_count != null ? ob.trade_count : '—'}건` + (rs.win_rate != null ? ` · WR ${rs.win_rate}%` : '') + (rs.pnl != null ? ` · PnL ${Number(rs.pnl).toLocaleString()}원` : ''), note: 'STOP_OB 합의 없음 · apply는 별도 스크립트/수동', }); } const ws = sum.whipsaw_recommend; if (!ws) { rows.push({ kind: '휩쏘', st: '미포함', nums: '—', stats: '—', note: '구 JSON / 미생성', }); } else if (!ws.ok) { rows.push({ kind: '휩쏘', st: '실패', nums: '—', stats: '—', note: ws.reason || 'n/a', }); } else { const p = ws.params || {}; const rs = ws.recommended_stats || {}; rows.push({ kind: '휩쏘', st: 'ok', nums: `subbar=${p.whipsaw_subbar_sec != null ? p.whipsaw_subbar_sec : '—'}` + ` · lookback=${p.whipsaw_lookback_sec != null ? p.whipsaw_lookback_sec : '—'}` + ` · dip=${p.whipsaw_dip_pct != null ? p.whipsaw_dip_pct : '—'}`, stats: (rs.win_rate != null ? `WR ${rs.win_rate}%` : '—') + (rs.pnl != null ? ` · PnL ${Number(rs.pnl).toLocaleString()}원` : ''), note: 'TAIL/BREAKOUT 등은 DB 적용 차단·표시만 가능', }); } const tr = sum.daily_trail_recommend; if (!tr) { rows.push({ kind: '다단트레일', st: '미포함', nums: '—', stats: '—', note: '—', }); } else if (!tr.ok) { rows.push({ kind: '다단트레일', st: '실패', nums: '—', stats: '—', note: tr.reason || '양수 PnL 앵커 없음', }); } else { rows.push({ kind: '다단트레일', st: 'ok', nums: `ARM ${Number(tr.arm_krw || 0).toLocaleString()} · ${tr.tiers || ''}`, stats: tr.anchor_krw != null ? `앵커 ${Number(tr.anchor_krw).toLocaleString()}원` : '—', note: 'Optuna DB 적용 시에만 반영', }); } rows.push({ kind: '손절호가 STOP_OB', st: '미구현', nums: '—', stats: '—', note: '후처리 합의·패치 없음 (DB ON은 수동)', }); tb.innerHTML = rows.map(r => ` ${r.kind} ${r.st} ${r.nums} ${r.stats} ${r.note} `).join(''); } function optunaRenderTop5(sum) { const tb = $('opt_top5_tbody'); if (!tb) return; const rows = (sum && sum.top5_gated) || []; if (!rows.length) { tb.innerHTML = 'gated 후보 없음 / 미완료'; } else { tb.innerHTML = rows.map(r => { const rank = r.rank || 1; return ` ${rank} #${r.optuna_trial_number ?? '—'} ${optunaFmtNum(r.total_trades, 0)} ${optunaFmtNum(r.win_rate, 1)}% ${optunaFmtNum(r.pf, 2)} ${optunaFmtNum(r.total_pnl)} `; }).join(''); } optunaRenderTop5Stable(sum); } function optunaRenderTop5Stable(sum) { const tb = $('opt_top5_stable_tbody'); if (!tb) return; const rows = (sum && sum.top5_stable) || []; if (!rows.length) { tb.innerHTML = 'stable 후보 없음 (구 JSON은 재실행 필요 / 게이트 미충족)'; return; } tb.innerHTML = rows.map(r => { const rank = r.rank || 1; return ` ${rank} #${r.optuna_trial_number ?? '—'} ${optunaFmtNum(r.total_trades, 0)} ${optunaFmtNum(r.win_rate, 1)}% ${optunaFmtNum(r.total_pnl)} ${r.n_losing_days != null ? optunaFmtNum(r.n_losing_days, 0) : '—'} ${r.worst_day_pnl != null ? optunaFmtNum(r.worst_day_pnl) : '—'} ${r.stability_score != null ? optunaFmtNum(r.stability_score, 2) : '—'} `; }).join(''); } function optunaHighlightGated(rank) { optunaShowCandidate('gated', rank || 1); } async function optunaShowCandidate(source, rank) { if (!_optunaJobId) { alert('선택된 job 없음 — 최근 잡에서「보기」로 job을 먼저 고르세요'); return; } const src = source || 'gated'; const rk = rank || 1; const title = $('opt_detail_title'); const met = $('opt_detail_metrics'); const pre = $('opt_detail_params'); if (title) title.textContent = `로딩 ${src} #${rk}…`; if (met) met.textContent = '불러오는 중…'; try { const qs = new URLSearchParams({ job_id: _optunaJobId, source: src, rank: String(rk), }); const r = await fetch('/api/optuna/candidate?' + qs.toString()); const j = await r.json(); if (!j.ok) { if (met) met.textContent = '❌ ' + (j.error || '실패'); if (pre) pre.textContent = '—'; return; } const m = j.metrics || {}; if (title) { title.textContent = `${j.strategy || ''} · ${m.label || src} · trial ${m.optuna_trial_number != null ? '#' + m.optuna_trial_number : '—'}`; } if (met) { const daily = m.daily_pnl && typeof m.daily_pnl === 'object' ? Object.keys(m.daily_pnl).sort().map(d => `${d}:${Number(m.daily_pnl[d]).toLocaleString()}`).join(' · ') : ''; met.innerHTML = `거래 ${optunaFmtNum(m.total_trades, 0)} · ` + `승률 ${optunaFmtNum(m.win_rate, 1)}% · ` + `PF ${optunaFmtNum(m.pf, 2)} · ` + `PnL ${optunaFmtNum(m.total_pnl)}` + (m.stability_score != null ? ` · 안정 ${optunaFmtNum(m.stability_score, 2)} (손실일 ${optunaFmtNum(m.n_losing_days, 0)} · 최악 ${optunaFmtNum(m.worst_day_pnl)})` : '') + ` · 파라미터 ${j.params_count || 0}개` + (j.start && j.end ? ` · 기간 ${j.start}~${j.end}` : '') + (daily ? `
일별 PnL: ${daily}
` : '') + ` ` + ` `; } if (pre) { pre.textContent = JSON.stringify(j.params_preview || j.params_full || {}, null, 2); } // 폼 반영용 캐시 window._optunaCandidateCache = { strategy: j.strategy, start: j.start, end: j.end, params: j.params_full || j.params_preview || {}, }; // Top5 행 하이라이트 document.querySelectorAll('#opt_top5_tbody tr').forEach((tr, i) => { tr.style.outline = (i + 1 === rk) ? '1px solid var(--accent)' : ''; }); const card = $('opt_detail_card'); if (card) card.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } catch (e) { if (met) met.textContent = '오류: ' + e; } } function optunaRenderJob(job) { if (!job) return; const prog = job.progress || {}; const pct = Number(prog.pct || 0); const fill = $('opt_tab_fill'); if (fill) { fill.style.width = `${Math.min(100, pct)}%`; fill.classList.toggle('is-done', job.status === 'done'); fill.classList.toggle('is-error', job.status === 'error'); } if ($('opt_st_status')) $('opt_st_status').textContent = job.status || '—'; if ($('opt_st_job')) $('opt_st_job').textContent = job.job_id || '—'; if ($('opt_st_study')) $('opt_st_study').textContent = job.study_name || '—'; if ($('opt_st_trials')) { const met = optunaBestMetricsText(prog, job); $('opt_st_trials').textContent = `${prog.trials_done ?? '—'} / ${prog.trials_total ?? job.trials ?? '—'} (${pct}%)` + (job.current_strategy ? ` · now=${job.current_strategy}` : '') + (met ? ` · ${met}` : ''); } if ($('opt_st_log')) $('opt_st_log').textContent = job.log_path || '—'; if ($('opt_st_json')) $('opt_st_json').textContent = job.result_json || '—'; const sum = job.result_summary; if ($('opt_st_top')) { if (sum && sum.top) { const t = sum.top; $('opt_st_top').innerHTML = `상위(${t.source}): trial #${t.optuna_trial_number} · ` + `거래 ${t.total_trades} · WR ${Number(t.win_rate || 0).toFixed(1)}% · ` + `PF ${Number(t.pf || 0).toFixed(2)} · PnL ${Number(t.total_pnl || 0).toLocaleString()}원` + ` · gated ${sum.n_gated}/${sum.n_all}` + (sum.n_stable != null ? ` · stable ${sum.n_stable}` : ''); } else { $('opt_st_top').textContent = job.error || '결과 요약 없음'; } } if ($('opt_log_tail')) $('opt_log_tail').textContent = job.log_tail || ''; if ($('opt_briefing')) { $('opt_briefing').textContent = job.briefing_preview || '—'; } const running = job.status === 'running'; const done = job.status === 'done'; if ($('opt_btn_start')) $('opt_btn_start').disabled = running; if ($('opt_btn_stop')) $('opt_btn_stop').disabled = !running; const applyBox = $('opt_apply_box'); if (applyBox) { applyBox.classList.toggle('d-none', !done); const canGate = !!(sum && sum.apply_ready); const canStable = !!(sum && sum.apply_stable_ready); if ($('opt_btn_apply_gated')) $('opt_btn_apply_gated').disabled = !canGate; if ($('opt_btn_apply_stable')) $('opt_btn_apply_stable').disabled = !canStable; if ($('opt_btn_apply_mode')) { const hasMode = !!(sum && (sum.compare_rows || []).some(r => r.source === 'mode')); $('opt_btn_apply_mode').disabled = !done || !hasMode; } } optunaRenderCompare(sum); optunaRenderTop5(sum); window._optunaLastSummary = sum || null; optunaSetNav(job); } async function optunaFillFormFromCandidate(source, rank) { // 캐시 없으면 다시 fetch let cache = window._optunaCandidateCache; if (!cache || !cache.params) { await optunaShowCandidate(source || 'gated', rank || 1); cache = window._optunaCandidateCache; } const p = (cache && cache.params) || {}; const strat = String((cache && cache.strategy) || '').toLowerCase(); const isUs = strat === 'us_momentum' || String(p.market || '').toUpperCase() === 'US'; const set = (id, val) => { const el = $(id); if (!el || val === undefined || val === null) return; if (el.type === 'checkbox') el.checked = !!(val === true || val === 1 || val === '1'); else el.value = val; }; const prefix = isUs ? 'usmom_' : 'mom_'; // 기간 = Optuna study 기간 (비교 시 필수) if (cache.start) setDateVal(isUs ? 'usmom_start' : 'mom_start', cache.start); if (cache.end) setDateVal(isUs ? 'usmom_end' : 'mom_end', cache.end); set(prefix + 'sl', p.sl_pct); set(prefix + 'tp', p.tp_pct); set(prefix + 'tp_max', p.tp_max_pct); set(prefix + 'slot_money', p.slot_money); set(prefix + (isUs ? 'slots' : 'slots'), p.max_stocks || p.slots || p.mom_slots); if (isUs) set('usmom_slots', p.max_stocks || p.slots); else set('mom_slots', p.max_stocks || p.mom_slots || p.slots); set(prefix + 'total_budget', p.total_budget_krw); set(prefix + 'cooldown', p.cooldown_min); set(prefix + 'smin', p.shoulder_min_high); set(prefix + 'scut', p.shoulder_cut_pct); set(prefix + 'trail', p.trail_pct); set(prefix + 'trail_arm', p.trail_arm_pct); set(prefix + 'max_hold', p.max_hold_bars); set(prefix + 'ratchet', p.ratchet_tiers != null ? p.ratchet_tiers : ''); set(prefix + 'time_start', p.time_start_hm || p.mom_time_start_hm); set(prefix + 'time_end', p.time_end_hm || p.mom_time_end_hm || p.mom_time_end); set(prefix + 'max_daily', p.max_daily); set(prefix + 'high_chase', p.high_chase_thr); set(prefix + 'max_daily_chg', p.max_daily_chg); set(prefix + 'min_price', p.min_price); set(prefix + 'max_loss', p.max_loss_krw); set(prefix + 'min_margin', p.min_margin); set(prefix + 'rsi_min', p.mom_rsi_min); set(prefix + 'rsi_max', p.mom_rsi_max); set(prefix + 'vol_mult', p.mom_vol_mult); set(prefix + 'vol_win', p.mom_vol_win); set(prefix + 'max_from_open', p.mom_max_from_open_pct); set(prefix + 'min_from_open', p.mom_min_from_open_pct); set(prefix + 'chase_lookback', p.chase_lookback_min); set(prefix + 'pullback_lookback', p.pullback_lookback_min); set(prefix + 'pullback_min', p.pullback_min_pct); set(prefix + 'pullback_max', p.pullback_max_pct); set(prefix + 'setup_vol', p.setup_vol_max_mult); set(prefix + 'setup_bear', p.setup_bear_bars_min); set(prefix + 'ema_fast', p.ema_fast_period); set(prefix + 'ema_slow', p.ema_slow_period); set(prefix + 'eod_hm', p.eod_hm); const defId = isUs ? 'usmom_use_defense' : 'mom_use_defense'; if ($(defId) && p.use_defense_filters !== undefined) $(defId).checked = !!p.use_defense_filters; const mapCb = [ ['use_high_chase_filter', isUs ? 'usmom_use_high_chase_f' : 'mom_use_high_chase_f'], ['use_daily_range_filter', isUs ? 'usmom_use_daily_range' : 'mom_use_daily_range'], ['use_ema_filter', isUs ? 'usmom_use_ema_f' : 'mom_use_ema_f'], ['use_rsi_max_filter', isUs ? 'usmom_use_rsi_max_f' : 'mom_use_rsi_max_f'], ['pattern_breakout', isUs ? 'usmom_pat_breakout' : 'mom_pat_breakout'], ['pattern_pullback', isUs ? 'usmom_pat_pullback' : 'mom_pat_pullback'], ['eod_enabled', isUs ? 'usmom_eod_enabled' : 'mom_eod_enabled'], ]; mapCb.forEach(([k, id]) => { if ($(id) && p[k] !== undefined) $(id).checked = !!p[k]; }); // 이미 해당 탭이면 click 금지 — us_momentum 탭 click 시 usmomLoadStocks→핀 cfg 가 // Optuna 폼 반영을 즉시 덮어씀 (폼에 넣기 무효화 버그). const wantTab = isUs ? 'us_momentum' : 'momentum'; const tab = document.querySelector(`[data-tab="${wantTab}"]`); if (tab && !tab.classList.contains('active')) tab.click(); alert( (isUs ? '해외 모멘텀' : '모멘텀') + ' 폼에 반영했습니다.\n기간 ' + (cache.start || '') + '~' + (cache.end || '') + '\n같은 기간으로 백테 실행해 Optuna와 비교하세요.\n(DB 저장 아님)' ); } async function optunaApply(source, rank) { if (!_optunaJobId) { alert('선택된 job 없음 — 최근 잡에서 보기 먼저'); return; } const src = source || 'gated'; const rk = rank || 1; let trailLine = ''; try { const sum = (window._optunaLastSummary) || null; const rec = sum && sum.daily_trail_recommend; if (rec && rec.ok) { trailLine = `\n\n다단트레일 추천도 함께 반영됩니다:\n` + `${rec.prefix}_DAILY_PROFIT_TRAIL_TIERS=${rec.tiers}\n` + `(ARM=${Number(rec.arm_krw || 0).toLocaleString()} · 이후 운영탭에서 끄기/수정 가능)`; } else if (rec && !rec.ok) { trailLine = `\n\n다단트레일 추천 없음(${rec.reason || 'n/a'}) — TRAIL 미변경`; } } catch (e) { /* ignore */ } const msg = (src === 'mode' ? 'mode_combo 파라미터를 DB에 적용할까요?\n(TIME_* 미변경 · 실매 즉시 영향)' : src === 'stable' ? `안정(stable) #${rk} 을 DB에 적용할까요?\n(손실일·최악일 게이트 통과 · TIME_* 미변경 · 실매 즉시 영향)` : `사후합격(gated) #${rk} 을 DB에 적용할까요?\n(TIME_* 미변경 · 실매 즉시 영향)`) + trailLine; if (!confirm(msg)) return; try { const r = await fetch('/api/optuna/apply', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ job_id: _optunaJobId, source: src, rank: rk }), }); const j = await r.json(); if (!j.ok) { alert('❌ ' + (j.error || '적용 실패')); return; } const m = j.metrics || {}; const ta = j.daily_trail_apply || {}; let trailOk = ''; if (ta.applied) { const patch = ta.patch || {}; const tiersKey = Object.keys(patch).find(k => k.endsWith('_DAILY_PROFIT_TRAIL_TIERS')); trailOk = `\n다단트레일: ${tiersKey ? patch[tiersKey] : '반영됨'}`; } else if (ta.reason) { trailOk = `\n다단트레일 미반영: ${ta.reason}`; } alert( `✅ ${j.strategy} 적용 완료` + (j.env_id != null ? ` (env_id=${j.env_id})` : '') + `\ntrial #${m.optuna_trial_number ?? '—'} · PnL ${m.total_pnl} · WR ${m.win_rate}` + trailOk + `\n${j.note || ''}`, ); } catch (e) { alert('오류: ' + e); } } async function optunaPollOnce() { if (!_optunaJobId) { // 외부 CLI / latest try { const r = await fetch('/api/optuna/active'); const j = await r.json(); if (j.running && j.running[0]) { _optunaJobId = j.running[0].job_id; localStorage.setItem(OPTUNA_LS_KEY, _optunaJobId); optunaRenderJob(j.running[0]); return; } if (j.latest) { optunaRenderJob(j.latest); if (j.latest.status === 'running') { _optunaJobId = j.latest.job_id; localStorage.setItem(OPTUNA_LS_KEY, _optunaJobId); } else if (j.latest.status === 'done' || j.latest.status === 'error') { optunaSetNav(j.latest); } } else if (j.external) { optunaSetNav({ label: 'CLI Optuna', status: 'running', progress: { pct: 5, trials_done: '?', trials_total: '?' }, study_name: j.external.cmdline, }); } else { optunaSetNav(null); } } catch (e) { /* ignore */ } return; } try { const r = await fetch('/api/optuna/status/' + encodeURIComponent(_optunaJobId)); const j = await r.json(); if (!j.ok) { _optunaJobId = ''; localStorage.removeItem(OPTUNA_LS_KEY); return; } optunaRenderJob(j.job); if (j.job.status === 'done' || j.job.status === 'error') { // 완료 후에도 상단 바는 잠시 유지 (클릭해 결과 확인) } } catch (e) { /* ignore */ } } function optunaStartPolling() { if (_optunaPollTimer) clearInterval(_optunaPollTimer); optunaPollOnce(); _optunaPollTimer = setInterval(optunaPollOnce, 3000); } function optunaSelectedStrategies() { const order = ['momentum', 'us_momentum', 'tail', 'breakout', 'scalp']; const checked = new Set( Array.from(document.querySelectorAll('.opt-strat-cb:checked')).map(cb => cb.value) ); return order.filter(s => checked.has(s)); } function optunaPickKr4() { const kr = new Set(['momentum', 'tail', 'breakout', 'scalp']); document.querySelectorAll('.opt-strat-cb').forEach(cb => { cb.checked = kr.has(cb.value); }); } function optunaPickNone() { document.querySelectorAll('.opt-strat-cb').forEach(cb => { cb.checked = false; }); } async function optunaStart() { const strategies = optunaSelectedStrategies(); const start = $('opt_start')?.value; const end = $('opt_end')?.value; const trials = parseInt($('opt_trials')?.value || '200', 10); const mode = $('opt_mode')?.value || 'tpe'; const univSrc = univHistorySourceParam('opt_univ_history_source'); const candleSrc = $('opt_candle_source')?.value || ''; const tickSrc = $('opt_tick_source')?.value || ''; const obSrc = $('opt_ob_source')?.value || ''; if (!strategies.length) { alert('전략을 1개 이상 체크하세요'); return; } if (!start || !end) { alert('시작·종료일을 입력하세요'); return; } const how = strategies.length >= 2 ? `순차 ${strategies.length}개` : strategies[0]; const srcLabel = univSrc === 'ls' ? 'LS' : '키움'; if (!confirm(`Optuna 시작?\n${how}: ${strategies.join(', ')}\n${start}~${end} trials=${trials}\n이력소스=${srcLabel}\n(DB 미적용)`)) return; try { const r = await fetch('/api/optuna/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ strategies, start, end, trials, mode, universe_history_source: univSrc, candle_source: candleSrc, tick_source: tickSrc, ob_source: obSrc, }), }); const j = await r.json(); if (!j.ok) { alert('❌ ' + (j.error || '시작 실패')); return; } _optunaJobId = j.job.job_id; localStorage.setItem(OPTUNA_LS_KEY, _optunaJobId); optunaRenderJob(j.job); optunaStartPolling(); optunaRefreshJobs(); } catch (e) { alert('오류: ' + e); } } async function optunaStop() { if (!_optunaJobId) return; if (!confirm('실행 중 Optuna를 중지할까요?')) return; try { const r = await fetch('/api/optuna/stop/' + encodeURIComponent(_optunaJobId), { method: 'POST' }); const j = await r.json(); if (!j.ok) { alert(j.error || '중지 실패'); return; } optunaRenderJob(j.job); optunaRefreshJobs(); } catch (e) { alert('오류: ' + e); } } async function optunaRefreshJobs() { try { const sort = $('opt_jobs_sort')?.value || 'started'; const r = await fetch('/api/optuna/jobs?limit=20&sort=' + encodeURIComponent(sort)); const j = await r.json(); const tb = $('opt_jobs_tbody'); if (!tb) return; if (!j.ok || !(j.jobs || []).length) { tb.innerHTML = '잡 없음'; return; } tb.innerHTML = j.jobs.map(job => { const prog = job.progress || {}; const jid = job.job_id; const label = job.label || job.strategy || ''; return ` ${jid} ${label} ${job.start || ''}~${job.end || ''} ${job.status || ''} ${prog.trials_done ?? '—'}/${prog.trials_total ?? job.trials ?? '—'} ${job.started_at || ''} ${job.finished_at || '—'} `; }).join(''); } catch (e) { const tb = $('opt_jobs_tbody'); if (tb) tb.innerHTML = `${e}`; } } function optunaWatch(jobId) { _optunaJobId = jobId; localStorage.setItem(OPTUNA_LS_KEY, jobId); optunaPollOnce(); optunaStartPolling(); } function optunaBoot() { optunaLoadDefaults(); optunaStartPolling(); } // ──────────────────────────────────────────── // 상단 매매 모의/실전 (KIS_MOCK) — DB 저장 + kis_trader_main 재시작 // ──────────────────────────────────────────── let _kisMockBusy = false; let _kisMockCurrent = null; // true=모의, false=실전, null=미로드 /** * WS 프로바이더 라벨 생성. * @param {string} prov - "kiwoom" | "kis" | "ls" | ... * @param {boolean} isMock * @returns {string} e.g. "키움(실)" | "KIS(모)" */ function _wsProvLabel(prov, isMock) { const nm = (prov || '').toLowerCase(); const name = nm === 'kiwoom' ? '키움' : nm === 'kis' ? 'KIS' : nm === 'ls' ? 'LS' : (prov || '?').toUpperCase(); return `${name}(${isMock ? '모' : '실'})`; } function kisMockApplyUi(d) { const mock = !!(d && d.kis_mock); _kisMockCurrent = mock; const btnM = $('kis_mock_btn_mock'); const btnL = $('kis_mock_btn_live'); if (btnM) { btnM.classList.toggle('active', mock); btnM.classList.toggle('btn-warning', mock); btnM.classList.toggle('btn-outline-warning', !mock); btnM.disabled = _kisMockBusy; } if (btnL) { btnL.classList.toggle('active', !mock); btnL.classList.toggle('btn-danger', !mock); btnL.classList.toggle('btn-outline-danger', mock); btnL.disabled = _kisMockBusy; } const st = $('kis_mock_status'); if (st) { const bot = d && d.bot_active ? 'active' : ((d && d.bot_status) || '?'); const label = (d && d.label) || (mock ? '모의' : '실전'); st.textContent = label + ' · ' + bot; st.className = mock ? 'text-warning' : 'text-danger'; st.style.fontSize = '11px'; st.style.whiteSpace = 'nowrap'; } const lss = $('live_source_status'); if (lss && d) { // 프로바이더 모의/실전 판정 const tickProv = (d.tick_provider || 'kiwoom').toLowerCase(); const obProv = (d.ob_provider || 'kiwoom').toLowerCase(); const tickMock = tickProv.includes('kis') ? !!d.kis_price_mock : !!d.kiwoom_mock; const obMock = obProv.includes('kis') ? !!d.kis_price_mock : !!d.kiwoom_mock; const tickLbl = _wsProvLabel(tickProv, tickMock); const obLbl = _wsProvLabel(obProv, obMock); // 틱 DB 적재 표시 const scopeShort = (d.tick_db_scope || '전체').replace('candidates', '후보'); const dbLbl = d.tick_db_enabled ? `DB:${scopeShort}` : 'DB:OFF'; lss.textContent = `시세:${tickLbl} | 호가:${obLbl} | ${dbLbl}`; // hover tooltip — 전체 상세 (캔들 소스 포함) const c = d.candle_source || '전체'; lss.title = [ `캔들 소스: ${c}`, `시세 우선: ${_wsProvLabel(tickProv, tickMock)} → 폴백 자동`, `호가 우선: ${_wsProvLabel(obProv, obMock)} → 폴백 자동`, `KIS 시세WS: 실전 고정 (is_mock=false)`, `틱 DB 저장: ${d.tick_db_enabled ? 'ON (' + (d.tick_db_scope || '?') + ')' : 'OFF'}`, ].join('\n'); lss.style.cursor = 'help'; } } async function kisMockLoad() { try { const r = await fetch('/api/ops/kis_mock'); const d = await r.json(); if (!d || !d.ok) { const st = $('kis_mock_status'); if (st) st.textContent = (d && d.error) || '조회실패'; return; } kisMockApplyUi(d); } catch (e) { const st = $('kis_mock_status'); if (st) st.textContent = '조회오류'; } } async function kisMockSet(wantMock) { if (_kisMockBusy) return; if (_kisMockCurrent === wantMock) { await kisMockLoad(); return; } const label = wantMock ? '모의' : '실전'; const msg = wantMock ? '매매를 모의 계좌로 전환합니다.\n시세는 실키 유지 · kis_trader_main 이 재시작됩니다.\n계속할까요?' : '⚠️ 매매를 실전 계좌로 전환합니다.\n실주문이 나갑니다. 시세는 실키 유지 · 봇이 재시작됩니다.\n정말 전환할까요?'; if (!confirm(msg)) return; _kisMockBusy = true; kisMockApplyUi({ kis_mock: _kisMockCurrent, label: _kisMockCurrent ? '모의' : '실전', bot_active: false, bot_status: '재시작중' }); const st = $('kis_mock_status'); if (st) { st.textContent = '저장·재시작중…'; st.className = 'text-muted'; } try { const r = await fetch('/api/ops/kis_mock', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kis_mock: !!wantMock }), }); const d = await r.json().catch(() => ({})); if (!r.ok || !d.ok) { alert( (d && d.error) || ('전환 실패 (HTTP ' + r.status + ')\n' + 'db_ok=' + (d && d.db_ok) + ' restart_ok=' + (d && d.restart_ok)) ); await kisMockLoad(); return; } kisMockApplyUi(d); alert('✅ 매매 ' + label + ' 전환 완료\n봇: ' + (d.bot_status || '') + '\n' + (d.note || '')); } catch (e) { alert('전환 오류: ' + e); await kisMockLoad(); } finally { _kisMockBusy = false; await kisMockLoad(); } } function kisMockBoot() { kisMockLoad(); } // ──────────────────────────────────────────── // 최초 로드 // ※ loadActual 은 /api/env/params 날짜 확정 후 호출 (위 fetch then) // ──────────────────────────────────────────── optunaBoot(); kisMockBoot();