ㅇ Changes: - Introduced the DART strategy to the trading system, including its configuration and integration into the existing framework. - Updated the database schema to include DART-specific tables for disclosures and watchlists. - Enhanced the backtesting and parameter search functionalities to support the DART strategy. - Implemented new rules for browser verification and API interactions to ensure compliance with the updated DART strategy. Impact: - These additions expand the trading capabilities of the system, allowing for more comprehensive analysis and execution of DART-related strategies, while maintaining system integrity and performance.
5316 lines
238 KiB
JavaScript
5316 lines
238 KiB
JavaScript
// ────────────────────────────────────────────
|
||
// 유틸
|
||
// ────────────────────────────────────────────
|
||
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 + '원';
|
||
};
|
||
/** 수익률(%) — 거래내역·가상체결 테이블 공용 */
|
||
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 ep = Math.round(Number(t.buy_price ?? t.avg_price ?? t.entry ?? 0));
|
||
const xp = Math.round(Number(t.sell_price ?? t.exit_price ?? t.exit ?? 0));
|
||
const qty = Number(t.qty || 0) || (ep > 0 ? Math.max(1, Math.floor(1000000 / ep)) : 0);
|
||
const pnl = Math.round(Number(t.pnl ?? t.realized_pnl ?? t.unrealized_pnl ?? 0));
|
||
let rateNum = Number(t.profit_rate);
|
||
if (!Number.isFinite(rateNum) && ep > 0) rateNum = (xp - ep) / ep * 100;
|
||
if (!Number.isFinite(rateNum)) rateNum = 0;
|
||
const isOpen = !!t.is_open;
|
||
const buyRaw = t.buy_time || t.entry_time || t.buy_date || '';
|
||
const sellRaw = isOpen ? '' : (t.sell_time || t.exit_time || t.sell_date || '');
|
||
const code = t.code || meta.code || '';
|
||
const name = t.name || meta.name || '';
|
||
return {
|
||
code,
|
||
name,
|
||
buyRaw,
|
||
sellRaw,
|
||
buyKey: _tradeTimeSortKey(buyRaw),
|
||
sellKey: isOpen ? _tradeTimeSortKey(buyRaw) : _tradeTimeSortKey(sellRaw),
|
||
ep,
|
||
xp,
|
||
qty,
|
||
pnl,
|
||
isOpen,
|
||
rateNum,
|
||
hold: t.hold_min ?? t.hold_days ?? t.hold_minutes ?? 0,
|
||
reason: t.sell_reason || t.reason || '',
|
||
rsi: t.rsi_entry,
|
||
entrySource: t.entry_source_label || t.entry_source || '',
|
||
exitSource: t.exit_source_label || t.exit_source || '',
|
||
debugTick: t.debug_tick || '',
|
||
cumPnl: (() => { const v = Number(t.cum_pnl); return Number.isFinite(v) ? Math.round(v) : NaN; })(),
|
||
cumReturnPct: Number(t.cum_return_pct ?? NaN),
|
||
};
|
||
}
|
||
|
||
function sortVirtualTradeRows(rows, mode) {
|
||
const list = [...rows];
|
||
const cmpExit = (a, b) => String(a.sellKey).localeCompare(String(b.sellKey));
|
||
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 || '거래내역';
|
||
let line = `📋 ${label} · 한도 ${tb > 0 ? fmtWon(tb) : '—'}원`;
|
||
if (peak > 0 && peakAt) {
|
||
line += ` | 장중 누적 최고 <b style="color:var(--green)">+${fmtWon(peak)}원</b> (${peakAt})`;
|
||
}
|
||
if (opts.totalPnl != null && Number.isFinite(Number(opts.totalPnl))) {
|
||
line += ` | 최종 누적 <b>${fmtKrw(opts.totalPnl)}</b>`;
|
||
}
|
||
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;
|
||
}
|
||
|
||
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 = opts.meta || {};
|
||
const rows = sortVirtualTradeRows(
|
||
(_virtualTradesCache[tbodyId] || []).map(t => normalizeVirtualTrade(t, meta)),
|
||
mode,
|
||
);
|
||
const tbody = $(tbodyId);
|
||
if (!tbody) return;
|
||
tbody.innerHTML = '';
|
||
rows.forEach(r => {
|
||
const pnlCls = r.pnl > 0 ? 'text-pnl-pos' : (r.pnl < 0 ? 'text-pnl-neg' : '');
|
||
const rateBadge = r.isOpen
|
||
? 'badge-flat'
|
||
: (r.rateNum > 0 ? 'badge-win' : (r.rateNum < 0 ? 'badge-loss' : 'badge-flat'));
|
||
const nameCell = (r.name || r.code)
|
||
? `${r.name || r.code}<br><span style="color:var(--muted);font-size:11px">${r.code || ''}</span>`
|
||
: (r.code || '-');
|
||
const reasonHtml = `<span class="badge-reason">${r.reason || '-'}</span>`;
|
||
const debugHtml = r.debugTick
|
||
? `<span class="badge badge-secondary" style="font-size:10px" title="체결 경로">${r.debugTick}</span>`
|
||
: (r.entrySource || r.exitSource
|
||
? `<span style="font-size:10px;color:var(--muted)">${r.entrySource || '-'}→${r.exitSource || '-'}</span>`
|
||
: '-');
|
||
const cumPnlHtml = Number.isFinite(r.cumPnl)
|
||
? `<span class="${r.cumPnl > 0 ? 'text-pnl-pos' : (r.cumPnl < 0 ? 'text-pnl-neg' : '')}">${fmtWon(r.cumPnl)}</span>`
|
||
: '-';
|
||
const cumPctHtml = Number.isFinite(r.cumReturnPct)
|
||
? `<span class="${r.cumReturnPct > 0 ? 'badge-win' : (r.cumReturnPct < 0 ? 'badge-loss' : 'badge-flat')}">${fmtPct(r.cumReturnPct)}</span>`
|
||
: '-';
|
||
const sellCell = r.isOpen
|
||
? '<span class="badge-flat">보유중</span>'
|
||
: `<span style="font-size:11px">${fmtTradeTime(r.sellRaw)}</span>`;
|
||
const exitPxCell = r.isOpen
|
||
? `${fmtWon(r.xp)} <span style="color:var(--muted);font-size:10px">현재가</span>`
|
||
: fmtWon(r.xp);
|
||
const pnlCell = r.isOpen
|
||
? `${fmtWon(r.pnl)} <span style="color:var(--muted);font-size:10px">평가</span>`
|
||
: fmtWon(r.pnl);
|
||
const rowStyle = r.isOpen ? ' style="background:rgba(210,153,34,.06)"' : '';
|
||
let html = `<tr${rowStyle}>
|
||
<td>${nameCell}</td>
|
||
<td style="font-size:11px">${fmtTradeTime(r.buyRaw)}</td>
|
||
<td style="font-size:11px">${sellCell}</td>
|
||
<td>${fmtWon(r.ep)}</td>
|
||
<td>${exitPxCell}</td>
|
||
<td>${r.qty}</td>
|
||
<td class="${pnlCls}">${pnlCell}</td>
|
||
<td><span class="${rateBadge}">${fmtPct(r.rateNum)}</span></td>`;
|
||
if (opts.showCumulative) {
|
||
html += `<td>${cumPnlHtml}</td><td>${cumPctHtml}</td>`;
|
||
}
|
||
html += `<td>${r.hold}</td>`;
|
||
if (opts.showDebug) html += `<td style="font-size:10px">${debugHtml}</td>`;
|
||
html += `<td style="font-size:11px">${reasonHtml}</td>`;
|
||
if (opts.showRsi) html += `<td>${r.rsi != null && r.rsi !== '' ? r.rsi : '-'}</td>`;
|
||
html += '</tr>';
|
||
tbody.insertAdjacentHTML('beforeend', html);
|
||
});
|
||
_syncTradeSortButtons(tbodyId, mode);
|
||
}
|
||
|
||
function setVirtualTradesSort(tbodyId, mode) {
|
||
_virtualTradesSort[tbodyId] = mode;
|
||
if (_virtualTradesCache[tbodyId]) {
|
||
renderVirtualTrades(tbodyId, _virtualTradesCache[tbodyId], _virtualTradesOpts[tbodyId] || {});
|
||
}
|
||
}
|
||
|
||
document.addEventListener('click', e => {
|
||
const btn = e.target.closest('.trade-sort-btn');
|
||
if (!btn) return;
|
||
const bar = btn.closest('.trade-sort-bar');
|
||
if (!bar || !bar.dataset.tbody) return;
|
||
setVirtualTradesSort(bar.dataset.tbody, btn.dataset.sort || 'exit_desc');
|
||
});
|
||
const colorPnl = (el, val) => {
|
||
if (!el) return;
|
||
el.classList.remove('green','red');
|
||
if (val > 0) el.classList.add('green');
|
||
else if (val < 0) el.classList.add('red');
|
||
};
|
||
|
||
// 차트 인스턴스 저장 (재생성용)
|
||
const charts = {};
|
||
function destroyChart(id) {
|
||
if (charts[id]) { charts[id].destroy(); delete charts[id]; }
|
||
}
|
||
|
||
const CHART_COLORS = {
|
||
blue: '#58a6ff', green: '#3fb950', red: '#f85149',
|
||
yellow: '#d29922', purple:'#bc8cff', orange:'#f0883e',
|
||
};
|
||
|
||
function lineChart(id, labels, data, label, color) {
|
||
destroyChart(id);
|
||
const el = $(id);
|
||
if (!el) return;
|
||
const ctx = el.getContext('2d');
|
||
if (!ctx) return;
|
||
charts[id] = new Chart(ctx, {
|
||
type: 'line',
|
||
data: {
|
||
labels,
|
||
datasets: [{
|
||
label, data,
|
||
borderColor: color || CHART_COLORS.blue,
|
||
borderWidth: 2,
|
||
pointRadius: data.length > 100 ? 0 : 3,
|
||
fill: { target: 'origin', above: 'rgba(63,185,80,0.08)', below: 'rgba(248,81,73,0.08)' },
|
||
tension: 0.3,
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true, animation: false,
|
||
plugins: { legend: { display: false },
|
||
tooltip: { callbacks: { label: ctx => fmt(ctx.parsed.y) + '원' } } },
|
||
scales: {
|
||
x: { ticks: { color: '#8b949e', maxTicksLimit: 12 }, grid: { color:'#21262d' } },
|
||
y: { ticks: { color: '#8b949e', callback: v => fmt(v) }, grid: { color:'#21262d' } },
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function barChart(id, labels, data, colors) {
|
||
destroyChart(id);
|
||
const el = $(id);
|
||
if (!el) return;
|
||
const ctx = el.getContext('2d');
|
||
if (!ctx) return;
|
||
charts[id] = new Chart(ctx, {
|
||
type: 'bar',
|
||
data: {
|
||
labels,
|
||
datasets: [{
|
||
data,
|
||
backgroundColor: colors || data.map(v => v >= 0 ? 'rgba(63,185,80,0.7)' : 'rgba(248,81,73,0.7)'),
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true, animation: false,
|
||
plugins: { legend: { display: false },
|
||
tooltip: { callbacks: { label: ctx => fmt(ctx.parsed.y) + '원' } } },
|
||
scales: {
|
||
x: { ticks: { color: '#8b949e', maxTicksLimit: 14 }, grid: { color:'#21262d' } },
|
||
y: { ticks: { color: '#8b949e', callback: v => fmt(v) }, grid: { color:'#21262d' } },
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function doughnutChart(id, labels, data) {
|
||
destroyChart(id);
|
||
const el = $(id);
|
||
if (!el) return;
|
||
const ctx = el.getContext('2d');
|
||
if (!ctx) return;
|
||
const palette = [CHART_COLORS.green, CHART_COLORS.red, CHART_COLORS.yellow,
|
||
CHART_COLORS.purple, CHART_COLORS.orange, CHART_COLORS.blue];
|
||
charts[id] = new Chart(ctx, {
|
||
type: 'doughnut',
|
||
data: {
|
||
labels,
|
||
datasets: [{ data, backgroundColor: palette, borderWidth: 0 }]
|
||
},
|
||
options: {
|
||
responsive: true, animation: false,
|
||
plugins: { legend: { position: 'right', labels: { color:'#8b949e', font:{ size:11 } } } }
|
||
}
|
||
});
|
||
}
|
||
|
||
// ────────────────────────────────────────────
|
||
// 탭 전환
|
||
// ────────────────────────────────────────────
|
||
document.querySelectorAll('[data-tab]').forEach(el => {
|
||
el.addEventListener('click', e => {
|
||
e.preventDefault();
|
||
document.querySelectorAll('[data-tab]').forEach(x => x.classList.remove('active'));
|
||
el.classList.add('active');
|
||
const tab = el.dataset.tab;
|
||
$('tab-actual').style.display = tab === 'actual' ? '' : 'none';
|
||
$('tab-dashboard').style.display = tab === 'dashboard'? '' : 'none';
|
||
$('tab-portfolio').style.display = tab === 'portfolio'? '' : 'none';
|
||
$('tab-backtest').style.display = tab === 'backtest' ? '' : 'none';
|
||
$('tab-tail').style.display = tab === 'tail' ? '' : 'none';
|
||
$('tab-dbband').style.display = tab === 'dbband' ? '' : 'none';
|
||
$('tab-breakout').style.display = tab === 'breakout' ? '' : 'none';
|
||
$('tab-range_break').style.display = tab === 'range_break' ? '' : 'none';
|
||
$('tab-momentum').style.display = tab === 'momentum' ? '' : 'none';
|
||
$('tab-holding').style.display = tab === 'holding' ? '' : 'none';
|
||
$('tab-updownbox').style.display = tab === 'updownbox'? '' : 'none';
|
||
$('tab-updow').style.display = tab === 'updow' ? '' : 'none';
|
||
$('tab-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 === 'dart') dartOnTabShow();
|
||
if (tab === 'updownbox') ubxOnTabShow();
|
||
if (tab === 'dbband') dbLoadStocks();
|
||
if (tab === 'breakout' && typeof boSyncSlModeColors === 'function') boSyncSlModeColors();
|
||
});
|
||
});
|
||
|
||
// ════════════════════════════════════════════════════════════════
|
||
// 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 => `<tr>
|
||
<td>${x.stock_code||''}</td><td>${x.corp_name||''}</td>
|
||
<td class="small">${(x.report_nm||'').slice(0,40)}</td>
|
||
<td class="small">${x.added_at||''}</td><td class="small">${x.expires_at||''}</td>
|
||
</tr>`).join('') : '<tr><td colspan="5" class="text-muted">워치 없음 (구독 스위치 OFF 또는 공시 대기)</td></tr>';
|
||
}
|
||
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 ? '<span class="text-success">통과</span>'
|
||
: ok === false ? `<span class="text-warning">${(x.filter_reason||'제외').slice(0,24)}</span>`
|
||
: '-';
|
||
const pct = (x.sales_pct != null && x.sales_pct !== '') ? Number(x.sales_pct).toFixed(1) : '-';
|
||
return `<tr>
|
||
<td class="small">${x.first_seen_at||''}</td>
|
||
<td>${x.stock_code||''}</td><td>${x.corp_name||''}</td>
|
||
<td class="small">${pct}</td>
|
||
<td class="small">${badge}</td>
|
||
<td class="small">${(x.report_nm||'').trim()}</td>
|
||
<td>${x.url ? `<a href="${x.url}" target="_blank" rel="noopener">원문</a>` : ''}</td>
|
||
</tr>`;
|
||
}).join('') : '<tr><td colspan="7" class="text-muted">공시 없음</td></tr>';
|
||
}
|
||
} 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)
|
||
// ════════════════════════════════════════════════════════════════
|
||
function permOnMarket() {
|
||
// 시장 선택 시 거래소 기본값 자동 보정 (KR→KRX, US→NASD)
|
||
const mk = $('perm_market') ? $('perm_market').value : 'KR';
|
||
if ($('perm_exch')) $('perm_exch').value = (mk === 'US') ? 'NASD' : 'KRX';
|
||
}
|
||
|
||
async function permLoad() {
|
||
const tb = $('perm_tbody');
|
||
if (!tb) return;
|
||
try {
|
||
const r = await fetch('/api/permanent_subs');
|
||
const j = await r.json();
|
||
const rows = (j && j.rows) || [];
|
||
if (!rows.length) { tb.innerHTML = '<tr><td colspan="9" class="text-muted">등록된 영구구독 없음</td></tr>'; return; }
|
||
tb.innerHTML = rows.map(function (x) {
|
||
const onBadge = x.enabled
|
||
? '<span class="badge bg-success">ON</span>'
|
||
: '<span class="badge bg-secondary">OFF</span>';
|
||
const mkBadge = (String(x.market_type).toUpperCase() === 'US')
|
||
? '<span class="badge bg-info text-dark">US</span>'
|
||
: '<span class="badge bg-primary">KR</span>';
|
||
return '<tr>'
|
||
+ '<td><b>' + x.code + '</b></td>'
|
||
+ '<td>' + mkBadge + '</td>'
|
||
+ '<td>' + (x.exchange || '') + '</td>'
|
||
+ '<td>' + (x.symbol || '') + '</td>'
|
||
+ '<td>' + (x.tf_min || '') + '</td>'
|
||
+ '<td><code>' + (x.ws_tr_key || '') + '</code></td>'
|
||
+ '<td>' + onBadge + '</td>'
|
||
+ '<td>' + (x.note || '') + '</td>'
|
||
+ '<td><button class="btn btn-sm btn-outline-secondary" onclick="permEdit(' + JSON.stringify(JSON.stringify(x)).replace(/"/g, '"') + ')">수정</button> '
|
||
+ '<button class="btn btn-sm btn-outline-danger" onclick="permDelete(\'' + x.code + '\')">삭제</button></td>'
|
||
+ '</tr>';
|
||
}).join('');
|
||
} catch (e) {
|
||
tb.innerHTML = '<tr><td colspan="9" class="text-danger">로드 실패: ' + e + '</td></tr>';
|
||
}
|
||
}
|
||
|
||
function permEdit(jsonStr) {
|
||
let x;
|
||
try { x = JSON.parse(jsonStr); } catch (e) { return; }
|
||
if ($('perm_code')) $('perm_code').value = x.code || '';
|
||
if ($('perm_market')) $('perm_market').value = String(x.market_type || 'KR').toUpperCase();
|
||
if ($('perm_exch')) $('perm_exch').value = x.exchange || 'KRX';
|
||
if ($('perm_symbol')) $('perm_symbol').value = x.symbol || '';
|
||
if ($('perm_tf')) $('perm_tf').value = x.tf_min || 15;
|
||
if ($('perm_note')) $('perm_note').value = x.note || '';
|
||
}
|
||
|
||
async function permSave() {
|
||
const code = ($('perm_code') ? $('perm_code').value : '').trim().toUpperCase();
|
||
if (!code) { alert('코드를 입력하세요'); return; }
|
||
const payload = {
|
||
code: code,
|
||
market_type: $('perm_market') ? $('perm_market').value : 'KR',
|
||
exchange: $('perm_exch') ? $('perm_exch').value : '',
|
||
symbol: $('perm_symbol') ? $('perm_symbol').value.trim().toUpperCase() : '',
|
||
tf_min: $('perm_tf') ? parseInt($('perm_tf').value || '15', 10) : 15,
|
||
enabled: true,
|
||
note: $('perm_note') ? $('perm_note').value : '',
|
||
};
|
||
try {
|
||
const r = await fetch('/api/permanent_subs/save', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
|
||
});
|
||
const j = await r.json();
|
||
if (!j.ok) { alert('저장 실패: ' + (j.error || '')); return; }
|
||
if ($('perm_code')) $('perm_code').value = '';
|
||
if ($('perm_symbol')) $('perm_symbol').value = '';
|
||
if ($('perm_note')) $('perm_note').value = '';
|
||
permLoad();
|
||
} catch (e) { alert('저장 오류: ' + e); }
|
||
}
|
||
|
||
async function permDelete(code) {
|
||
if (!confirm(code + ' 영구구독을 삭제할까요?')) return;
|
||
try {
|
||
const r = await fetch('/api/permanent_subs/delete', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code: code }),
|
||
});
|
||
const j = await r.json();
|
||
if (!j.ok && j.error) { alert('삭제 실패: ' + j.error); }
|
||
permLoad();
|
||
} catch (e) { alert('삭제 오류: ' + e); }
|
||
}
|
||
|
||
// ────────────────────────────────────────────
|
||
// 오늘 운영 대시보드
|
||
// ────────────────────────────────────────────
|
||
function dashFmtKrw(n) {
|
||
return fmtKrw(n);
|
||
}
|
||
function dashFmtPct(n) {
|
||
const v = Number(n) || 0;
|
||
return (v >= 0 ? '+' : '') + v.toFixed(2) + '%';
|
||
}
|
||
function dashPnlCls(n) {
|
||
return Number(n) >= 0 ? 'text-pnl-pos' : 'text-pnl-neg';
|
||
}
|
||
function dashInitDate() {
|
||
const el = $('dash_date');
|
||
if (!el) return;
|
||
if (!el.value) {
|
||
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 = '';
|
||
(d.strategies || []).forEach(row => {
|
||
const onBadge = row.enabled
|
||
? '<span class="badge-win">ON</span>'
|
||
: '<span class="badge-loss">OFF</span>';
|
||
const sid = row.strategy_id;
|
||
const pnlCls = dashPnlCls(row.realized_pnl_krw);
|
||
const retCls = dashPnlCls(row.return_pct);
|
||
tbody.insertAdjacentHTML('beforeend', `
|
||
<tr>
|
||
<td>${row.label || sid}<br><code style="font-size:11px">${sid}</code></td>
|
||
<td>${onBadge}</td>
|
||
<td class="text-end">${dashFmtKrw(row.buy_turnover_krw)}</td>
|
||
<td class="text-end">${dashFmtKrw(row.sell_turnover_krw)}</td>
|
||
<td class="text-end">${dashFmtKrw(row.turnover_krw)}</td>
|
||
<td class="text-end">${row.closed_trades}</td>
|
||
<td class="text-end">${row.open_positions}</td>
|
||
<td class="text-end ${pnlCls}">${dashFmtKrw(row.realized_pnl_krw)}</td>
|
||
<td class="text-end">${dashFmtKrw(row.budget_limit_krw)}</td>
|
||
<td class="text-end">${dashFmtKrw(row.budget_used_krw)}</td>
|
||
<td class="text-end text-muted">${dashFmtKrw(row.budget_now_krw || 0)}</td>
|
||
<td class="text-end">${dashFmtPct(row.budget_usage_pct)}</td>
|
||
<td class="text-end ${retCls}">${dashFmtPct(row.return_pct)}</td>
|
||
<td><button class="btn btn-sm btn-link p-0" style="font-size:11px"
|
||
onclick="dashDrill('${sid}')">상세</button></td>
|
||
</tr>`);
|
||
});
|
||
$('dash_ft_buy').textContent = dashFmtKrw(tot.buy_turnover_krw);
|
||
$('dash_ft_sell').textContent = dashFmtKrw(tot.sell_turnover_krw);
|
||
$('dash_ft_turn').textContent = dashFmtKrw(tot.turnover_krw);
|
||
$('dash_ft_closed').textContent = String(tot.closed_trades);
|
||
$('dash_ft_open').textContent = String(tot.open_positions);
|
||
$('dash_ft_pnl').textContent = dashFmtKrw(tot.realized_pnl_krw);
|
||
$('dash_ft_pnl').className = 'text-end ' + dashPnlCls(tot.realized_pnl_krw);
|
||
$('dash_ft_limit').textContent = dashFmtKrw(tot.budget_limit_krw);
|
||
$('dash_ft_used').textContent = dashFmtKrw(tot.budget_used_krw);
|
||
$('dash_ft_now').textContent = dashFmtKrw(tot.budget_now_krw || 0);
|
||
$('dash_ft_usage').textContent = dashFmtPct(tot.budget_usage_pct);
|
||
$('dash_ft_ret').textContent = dashFmtPct(tot.return_pct);
|
||
$('dash_ft_ret').className = 'text-end ' + dashPnlCls(tot.return_pct);
|
||
const notes = d.notes || {};
|
||
$('dash_notes').textContent =
|
||
(notes.turnover || '') + ' · ' + (notes.budget_used || '') + ' · '
|
||
+ (notes.budget_now || '') + ' · ' + (notes.return_pct || '');
|
||
} catch (e) {
|
||
alert('대시보드 오류: ' + e);
|
||
} finally {
|
||
showSpinner(false);
|
||
}
|
||
}
|
||
function dashDrill(strategyId) {
|
||
const radio = document.querySelector('input[name=act_strategy][value="' + strategyId + '"]');
|
||
if (radio) radio.checked = true;
|
||
const day = $('dash_date').value;
|
||
if (day) {
|
||
$('act_start').value = day;
|
||
$('act_end').value = day;
|
||
}
|
||
document.querySelectorAll('[data-tab]').forEach(x => x.classList.remove('active'));
|
||
const actTab = document.querySelector('[data-tab="actual"]');
|
||
if (actTab) actTab.classList.add('active');
|
||
$('tab-dashboard').style.display = 'none';
|
||
$('tab-actual').style.display = '';
|
||
$('tab-portfolio').style.display = 'none';
|
||
$('tab-backtest').style.display = 'none';
|
||
$('tab-tail').style.display = 'none';
|
||
$('tab-breakout').style.display = 'none';
|
||
$('tab-range_break').style.display = 'none';
|
||
$('tab-momentum').style.display = 'none';
|
||
$('tab-holding').style.display = 'none';
|
||
$('tab-updow').style.display = 'none';
|
||
loadActual();
|
||
}
|
||
function dashGoActual() {
|
||
dashDrill(document.querySelector('input[name=act_strategy]:checked')?.value || 'SHORT');
|
||
}
|
||
dashInitDate();
|
||
|
||
// ────────────────────────────────────────────
|
||
// 보유·매도 (active_trades + OrderManager 시장가)
|
||
// ────────────────────────────────────────────
|
||
// 보유·매도 정렬 상태 (다른 탭의 정렬바와 동일 UX, 데이터 스키마만 다름)
|
||
let _pfItems = [];
|
||
let _pfMeta = null;
|
||
let _pfSort = 'pnl_desc';
|
||
|
||
/** 보유·매도 정렬 — 수익률/평가금/종목명/미등록 우선 */
|
||
function pfSortItems(items, mode) {
|
||
const arr = [...(items || [])];
|
||
const num = v => (Number.isFinite(Number(v)) ? Number(v) : 0);
|
||
switch (mode) {
|
||
case 'pnl_asc': return arr.sort((a, b) => num(a.pnl_pct) - num(b.pnl_pct));
|
||
case 'eval_desc': return arr.sort((a, b) => num(b.eval_amt) - num(a.eval_amt));
|
||
case 'eval_asc': return arr.sort((a, b) => num(a.eval_amt) - num(b.eval_amt));
|
||
case 'name_asc': return arr.sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''), 'ko'));
|
||
case 'untracked_first':
|
||
return arr.sort((a, b) => (b.untracked ? 1 : 0) - (a.untracked ? 1 : 0) || num(b.eval_amt) - num(a.eval_amt));
|
||
case 'pnl_desc':
|
||
default: return arr.sort((a, b) => num(b.pnl_pct) - num(a.pnl_pct));
|
||
}
|
||
}
|
||
|
||
async function pfLoad(withBroker) {
|
||
const stratEl = document.querySelector('input[name=pf_strategy]:checked');
|
||
const strat = stratEl ? stratEl.value : 'ALL';
|
||
const brokerQ = withBroker ? '&broker=1' : '';
|
||
showSpinner(true);
|
||
try {
|
||
const r = await fetch(
|
||
'/api/portfolio/active?strategy=' + encodeURIComponent(strat) + brokerQ
|
||
);
|
||
const d = await r.json();
|
||
if (!d.ok) { alert(d.error || '조회 실패'); return; }
|
||
_pfItems = d.items || [];
|
||
_pfMeta = {
|
||
kis_mock: d.kis_mock,
|
||
with_broker: d.with_broker,
|
||
broker_codes: d.broker_codes,
|
||
count: d.count,
|
||
account: d.account || null,
|
||
};
|
||
pfRenderAccountSummary(d.account, d.with_broker);
|
||
pfRenderTable();
|
||
} catch (e) {
|
||
alert('보유 조회 오류: ' + e);
|
||
} finally {
|
||
showSpinner(false);
|
||
}
|
||
}
|
||
|
||
/** 보유·매도 — 매수시각 강조 (날짜 + 시각 분리) */
|
||
function pfFmtBuyDate(s) {
|
||
if (!s) return '<span class="text-muted">—</span>';
|
||
const t = String(s).trim().replace('T', ' ');
|
||
const d = t.slice(0, 10);
|
||
const tm = t.length >= 16 ? t.slice(11, 16) : (t.length > 10 ? t.slice(11) : '');
|
||
if (tm) {
|
||
return `<b>${d}</b><br><span style="color:var(--accent);font-size:12px;font-weight:600">${tm}</span>`;
|
||
}
|
||
return `<b>${d}</b>`;
|
||
}
|
||
|
||
/** 실계좌 대조 시 상단 — 입금액·예수금·평가금 */
|
||
function pfRenderAccountSummary(acct, withBroker) {
|
||
const row = $('pf_account_row');
|
||
if (!row) return;
|
||
if (!withBroker || !acct) {
|
||
row.style.display = 'none';
|
||
return;
|
||
}
|
||
row.style.display = '';
|
||
const set = (id, v, suffix) => {
|
||
const el = $(id);
|
||
if (!el) return;
|
||
el.textContent = (v != null && v !== '') ? fmtWon(v) + (suffix || '원') : '—';
|
||
};
|
||
set('pf_total_deposit', acct.total_deposit, '원');
|
||
set('pf_cash', acct.cash, '원');
|
||
set('pf_holdings_eval', acct.holdings_eval, '원');
|
||
set('pf_total_asset', acct.total_asset, '원');
|
||
}
|
||
|
||
/** 캐시된 _pfItems 를 현재 정렬(_pfSort)로 렌더 (재조회 없이 정렬만 갱신) */
|
||
function pfRenderTable() {
|
||
const d = _pfMeta || {};
|
||
const tbody = $('pf_tbody');
|
||
if (!tbody) return;
|
||
tbody.innerHTML = '';
|
||
const items = pfSortItems(_pfItems, _pfSort);
|
||
if ($('pf_empty')) $('pf_empty').style.display = items.length ? 'none' : '';
|
||
const mockLbl = d.kis_mock ? '모의투자' : '실전';
|
||
const untracked = items.filter(x => x.untracked).length;
|
||
const brNote = d.with_broker
|
||
? ` · 실계좌 ${d.broker_codes}종목` + (untracked ? ` (미등록 ${untracked})` : '')
|
||
: ' · 「실계좌 대조」필요';
|
||
if ($('pf_meta')) {
|
||
$('pf_meta').textContent =
|
||
`${mockLbl} · 표시 ${d.count}건${brNote} · ${new Date().toLocaleString('ko-KR')}`;
|
||
}
|
||
// 정렬 버튼 active 동기화
|
||
document.querySelectorAll('#pf_sort_bar .pf-sort-btn').forEach(btn => {
|
||
btn.classList.toggle('active', btn.dataset.sort === _pfSort);
|
||
});
|
||
items.forEach(it => {
|
||
const pnlCls = it.pnl_pct >= 0 ? 'text-pnl-pos' : 'text-pnl-neg';
|
||
let syncBadge = '<span class="text-muted">—</span>';
|
||
if (d.with_broker) {
|
||
if (it.untracked) {
|
||
let lbl, cls;
|
||
if (it.origin === 'holding') {
|
||
lbl = '홀딩봇'; cls = 'badge-win';
|
||
} else if (it.origin === 'bot') {
|
||
lbl = '봇고아'; cls = 'badge-loss';
|
||
} else {
|
||
lbl = '수동(보호)'; cls = 'badge-flat';
|
||
}
|
||
syncBadge = '<span class="' + cls + '" title="' + (it.sync_note || '') + '">' + lbl + '</span>';
|
||
} else if (it.sync_ok) {
|
||
syncBadge = '<span class="badge-win">OK</span>';
|
||
} else {
|
||
syncBadge = '<span class="badge-loss" title="' + (it.sync_note || 'DB·실계좌 수량 불일치') + '">주의</span>';
|
||
}
|
||
}
|
||
const codeEsc = String(it.code).replace(/'/g, "\\'");
|
||
const stratEsc = String(it.strategy || '').replace(/'/g, "\\'");
|
||
const stratLbl = it.strategy ? '<code>' + it.strategy + '</code>' : '<span class="text-muted">—</span>';
|
||
const sellBtn = it.can_sell
|
||
? `<button class="btn btn-sm btn-danger" style="font-size:11px"
|
||
onclick="pfSell('${codeEsc}','${stratEsc}','${String(it.name).replace(/'/g, '')}',${it.db_qty})">
|
||
시장가 전량</button>`
|
||
: '<span class="text-muted" style="font-size:11px" title="active_trades 없음">매도불가</span>';
|
||
tbody.insertAdjacentHTML('beforeend', `
|
||
<tr>
|
||
<td>${stratLbl}</td>
|
||
<td>${it.name}<br><small class="text-muted">${it.code}</small></td>
|
||
<td class="text-nowrap">${pfFmtBuyDate(it.buy_date)}</td>
|
||
<td>${it.db_qty}</td>
|
||
<td>${it.broker_qty == null ? '—' : it.broker_qty}</td>
|
||
<td>${syncBadge}</td>
|
||
<td>${it.buy_price > 0 ? fmtWon(it.buy_price) : '—'}</td>
|
||
<td>${it.broker_avg_price != null && it.broker_avg_price > 0
|
||
? fmtWon(it.broker_avg_price)
|
||
: '—'}</td>
|
||
<td>${fmtWon(it.current_price)}</td>
|
||
<td class="${pnlCls}">${it.pnl_pct >= 0 ? '+' : ''}${it.pnl_pct}%</td>
|
||
<td>${fmtWon(it.eval_amt)}</td>
|
||
<td>${sellBtn}</td>
|
||
</tr>`);
|
||
});
|
||
}
|
||
|
||
// 보유·매도 정렬 버튼 (다른 탭과 동일 UX) — pf-sort-btn 클릭 시 재조회 없이 정렬만 갱신
|
||
document.addEventListener('click', e => {
|
||
const btn = e.target.closest('#pf_sort_bar .pf-sort-btn');
|
||
if (!btn) return;
|
||
_pfSort = btn.dataset.sort || 'pnl_desc';
|
||
pfRenderTable();
|
||
});
|
||
|
||
async function pfSell(code, strategy, name, qty) {
|
||
const msg =
|
||
`[${strategy}] ${name}(${code})\n` +
|
||
`DB 보유 ${qty}주를 시장가 전량 매도합니다.\n` +
|
||
`OrderManager 매도 경로로 DB(trade_history) 동기화됩니다.\n\n` +
|
||
`HTS 에서 이미 팔았다면 실계좌 0주일 수 있으며, 그 경우 유령잔고 정리만 됩니다.\n계속할까요?`;
|
||
if (!confirm(msg)) return;
|
||
showSpinner(true);
|
||
try {
|
||
const r = await fetch('/api/portfolio/sell', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ code, strategy }),
|
||
});
|
||
const d = await r.json();
|
||
if (d.ok) {
|
||
alert(
|
||
`매도 접수 완료\nODNO: ${d.ord_no || '-'}\n` +
|
||
`체결 ${d.filled_qty || '-'}주 @ ${Number(d.filled_avg_price || 0).toLocaleString()}원`
|
||
);
|
||
pfLoad(false);
|
||
} else {
|
||
alert('매도 실패: ' + (d.error || d.detail || 'unknown'));
|
||
}
|
||
} catch (e) {
|
||
alert('매도 요청 오류: ' + e);
|
||
} finally {
|
||
showSpinner(false);
|
||
}
|
||
}
|
||
|
||
/** 미등록 보유분 중 '봇 고아'만 전량 시장가 일괄매도 — 수동매수/보호목록은 제외 */
|
||
async function pfSellUntrackedAll() {
|
||
if (!_pfMeta || !_pfMeta.with_broker) {
|
||
alert('먼저 「📡 실계좌 대조」를 눌러 실계좌 잔고를 불러온 뒤 실행하세요.');
|
||
return;
|
||
}
|
||
const untracked = (_pfItems || []).filter(x => x.untracked && (x.broker_qty || 0) > 0);
|
||
// 봇 고아(origin==='bot')만 매도 대상, 수동(origin==='manual')은 보호
|
||
const targets = untracked.filter(x => x.origin === 'bot');
|
||
const manual = untracked.filter(x => x.origin !== 'bot');
|
||
if (!targets.length) {
|
||
alert(
|
||
'매도 대상(봇 고아) 종목이 없습니다.' +
|
||
(manual.length ? `\n수동매수 추정 ${manual.length}종목은 보호되어 제외됩니다.` : '')
|
||
);
|
||
return;
|
||
}
|
||
const totalEval = targets.reduce((s, x) => s + (Number(x.eval_amt) || 0), 0);
|
||
const preview = targets.slice(0, 15)
|
||
.map(x => ` · ${x.name}(${x.code}) ${x.broker_qty}주`).join('\n');
|
||
const more = targets.length > 15 ? `\n …외 ${targets.length - 15}종목` : '';
|
||
const manualNote = manual.length
|
||
? `\n\n🛡️ 보호(제외) ${manual.length}종목: 수동매수 추정/보호목록\n` +
|
||
manual.slice(0, 10).map(x => ` · ${x.name}(${x.code})`).join('\n')
|
||
: '';
|
||
const msg =
|
||
`⚠️ 봇 고아(미기록 체결) ${targets.length}종목을 시장가로 전량 매도합니다.\n` +
|
||
`예상 평가금 합계: 약 ${Number(totalEval).toLocaleString()}원\n\n` +
|
||
`${preview}${more}${manualNote}\n\n` +
|
||
`※ 봇 관리분(active_trades)·수동매수분은 건드리지 않습니다.\n` +
|
||
`※ 시장가 주문이라 슬리피지가 발생할 수 있습니다.\n계속할까요?`;
|
||
if (!confirm(msg)) return;
|
||
showSpinner(true);
|
||
try {
|
||
const r = await fetch('/api/portfolio/sell_untracked_all', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({}),
|
||
});
|
||
const d = await r.json();
|
||
if (d.ok) {
|
||
let out = `일괄매도 접수: 성공 ${d.sold_count || 0}건 / 실패 ${d.failed_count || 0}건`
|
||
+ (d.protected_count ? ` / 보호 ${d.protected_count}건` : '');
|
||
if ((d.failed || []).length) {
|
||
out += '\n\n[실패]\n' + d.failed.slice(0, 10)
|
||
.map(f => ` · ${f.name}(${f.code}): ${f.error}`).join('\n');
|
||
}
|
||
if (d.msg) out = d.msg;
|
||
alert(out);
|
||
pfLoad(true); // 실계좌 재대조
|
||
} else {
|
||
alert('일괄매도 실패: ' + (d.error || 'unknown'));
|
||
}
|
||
} catch (e) {
|
||
alert('일괄매도 요청 오류: ' + e);
|
||
} finally {
|
||
showSpinner(false);
|
||
}
|
||
}
|
||
|
||
/** 봇 고아(active_trades 미기록) 수동 DB복구 — 매도 아님. 장마감 배치와 동일 로직 */
|
||
async function pfReconcileOrphans() {
|
||
const msg =
|
||
`봇이 매수했지만 active_trades 에 누락된 종목을 DB로 복구합니다.\n` +
|
||
`(매도가 아니라 봇이 관리하도록 등록만 — 이후 손절/익절 신호가 작동)\n\n` +
|
||
`※ 수동매수(주문기록 없음)·MANUAL_HOLD_CODES 종목은 제외됩니다.\n계속할까요?`;
|
||
if (!confirm(msg)) return;
|
||
showSpinner(true);
|
||
try {
|
||
const r = await fetch('/api/portfolio/reconcile_orphans', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({}),
|
||
});
|
||
const d = await r.json();
|
||
if (d.ok) {
|
||
let out = `봇고아 DB복구: ${d.reconciled_count || 0}종목 복구`
|
||
+ (d.failed_count ? ` / 실패 ${d.failed_count}건` : '')
|
||
+ `\n(제외: 수동 ${d.skipped_manual_count || 0} · 이미추적 ${d.skipped_tracked_count || 0} · 주문없음 ${d.skipped_no_order_count || 0})`;
|
||
if ((d.reconciled || []).length) {
|
||
out += '\n\n[복구]\n' + d.reconciled.slice(0, 12)
|
||
.map(x => ` · ${x.name}(${x.code}) [${x.strategy}] ${x.qty}주`).join('\n');
|
||
}
|
||
if ((d.failed || []).length) {
|
||
out += '\n\n[실패]\n' + d.failed.slice(0, 8)
|
||
.map(f => ` · ${f.name}(${f.code}): ${f.error}`).join('\n');
|
||
}
|
||
alert(out);
|
||
pfLoad(true); // 실계좌 재대조 (복구분이 봇추적으로 전환됨)
|
||
} else {
|
||
alert('고아복구 실패: ' + (d.error || 'unknown'));
|
||
}
|
||
} catch (e) {
|
||
alert('고아복구 요청 오류: ' + e);
|
||
} finally {
|
||
showSpinner(false);
|
||
}
|
||
}
|
||
|
||
// ────────────────────────────────────────────
|
||
// 날짜 기본값 설정 (KST = 한국장 기준, 브라우저 타임존과 무관)
|
||
// ────────────────────────────────────────────
|
||
function fmtKstDate(d) {
|
||
return new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Seoul' }).format(d);
|
||
}
|
||
function kstTodayParts() {
|
||
const iso = fmtKstDate(new Date());
|
||
const [y, m, day] = iso.split('-').map(x => parseInt(x, 10));
|
||
return { y, m, day, iso };
|
||
}
|
||
function kstMonthStartIso() {
|
||
const t = kstTodayParts();
|
||
return `${t.y}-${String(t.m).padStart(2, '0')}-01`;
|
||
}
|
||
function kstMonthAgoIso() {
|
||
const t = kstTodayParts();
|
||
let idx = t.y * 12 + (t.m - 1) - 1;
|
||
const ny = Math.floor(idx / 12);
|
||
const nm = (idx % 12) + 1;
|
||
const lastDay = new Date(ny, nm, 0).getDate();
|
||
const nd = Math.min(t.day, lastDay);
|
||
return `${ny}-${String(nm).padStart(2, '0')}-${String(nd).padStart(2, '0')}`;
|
||
}
|
||
/** 현재 KST 시(0~23) */
|
||
function kstHourNow() {
|
||
const parts = new Intl.DateTimeFormat('en-US', {
|
||
timeZone: 'Asia/Seoul', hour: 'numeric', hour12: false,
|
||
}).formatToParts(new Date());
|
||
const h = parts.find(p => p.type === 'hour');
|
||
return h ? (parseInt(h.value, 10) % 24) : 12;
|
||
}
|
||
function kstYesterdayIso() {
|
||
return fmtKstDate(new Date(Date.now() - 24 * 3600 * 1000));
|
||
}
|
||
/** N일 전 KST 날짜 (백테 기본 시작일 등) */
|
||
function kstDaysAgoIso(n) {
|
||
return fmtKstDate(new Date(Date.now() - n * 24 * 3600 * 1000));
|
||
}
|
||
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('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 || {});
|
||
|
||
// 돌파 탭 — 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 ($('bo_daily_trail_tiers')) {
|
||
$('bo_daily_trail_tiers').value = (b.daily_trail_tiers != null ? String(b.daily_trail_tiers) : '');
|
||
}
|
||
if (b.daily_profit_mode && $('bo_daily_profit_mode')) {
|
||
$('bo_daily_profit_mode').value = b.daily_profit_mode;
|
||
}
|
||
btFillPresetOptions('bo_daily_trail_preset', b.daily_trail_presets);
|
||
|
||
const rb = d.range_break || {};
|
||
setM('rb_box_lb', rb.box_lookback_min);
|
||
setM('rb_box_max_w', rb.box_max_width_pct);
|
||
setM('rb_setup_vol', rb.setup_vol_max_mult);
|
||
setM('rb_bear_min', rb.setup_bear_bars_min);
|
||
setM('rb_vol_win', rb.vol_window);
|
||
setM('rb_vol_mult', rb.vol_mult);
|
||
setM('rb_sl', rb.sl_pct);
|
||
setM('rb_tp', rb.tp_pct);
|
||
setM('rb_trail', rb.trail_pct);
|
||
if (rb.trail_arm_pct != null) setM('rb_trail_arm', rb.trail_arm_pct);
|
||
setM('rb_shoulder_smin', rb.shoulder_min_high_pct);
|
||
setM('rb_shoulder_scut', rb.shoulder_cut_pct);
|
||
setM('rb_time_start', rb.time_start_hm);
|
||
setM('rb_time_end', rb.time_end_hm);
|
||
setM('rb_max_daily', rb.max_daily);
|
||
setM('rb_cooldown', rb.cooldown_min);
|
||
setM('rb_max_chg', rb.max_daily_chg);
|
||
setM('rb_min_price', rb.min_price);
|
||
setM('rb_max_loss', rb.max_loss_krw);
|
||
if (rb.use_high_chase_filter !== undefined && $('rb_use_high_chase')) {
|
||
$('rb_use_high_chase').checked = !!rb.use_high_chase_filter;
|
||
}
|
||
setM('rb_slot', rb.slot_money);
|
||
if (rb.max_stocks != null) setM('rb_max_stocks', rb.max_stocks);
|
||
if (rb.total_budget_krw != null) setM('rb_total_budget', rb.total_budget_krw);
|
||
})
|
||
.catch(() => { /* DB 연결 실패 시 HTML 기본값 유지 */ });
|
||
|
||
const defChk = $('bt_use_defense');
|
||
if (defChk) {
|
||
defChk.addEventListener('change', saveScalpDefenseRealtime);
|
||
}
|
||
const macdChk = $('bt_use_macd');
|
||
if (macdChk) {
|
||
macdChk.addEventListener('change', saveScalpMacdRealtime);
|
||
}
|
||
|
||
})();
|
||
|
||
// ────────────────────────────────────────────
|
||
// 실거래 분석 로드
|
||
// ────────────────────────────────────────────
|
||
function loadActual() {
|
||
const strategy = document.querySelector('input[name=act_strategy]:checked').value;
|
||
const start = $('act_start').value;
|
||
const end = $('act_end').value;
|
||
showSpinner(true);
|
||
fetch(`/api/actual?strategy=${encodeURIComponent(strategy)}&start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}`)
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
showSpinner(false);
|
||
renderActual(d);
|
||
})
|
||
.catch(err => { showSpinner(false); alert('오류: ' + err); });
|
||
}
|
||
|
||
function renderActual(d) {
|
||
const s = d.summary;
|
||
const p = d.params || {};
|
||
const filtered = Number((d.meta || {}).filtered_forced_rows || 0);
|
||
|
||
$('a_total').textContent = fmt(s.total_trades) + '건';
|
||
$('a_winrate').textContent = s.win_rate + '%';
|
||
colorPnl($('a_winrate'), s.win_rate - 50);
|
||
|
||
$('a_pnl').textContent = fmtKrw(s.total_pnl);
|
||
colorPnl($('a_pnl'), s.total_pnl);
|
||
|
||
$('a_pf').textContent = s.profit_factor >= 999 ? '∞' : s.profit_factor;
|
||
colorPnl($('a_pf'), s.profit_factor - 1);
|
||
|
||
$('a_mdd').textContent = '-' + fmtWon(s.max_drawdown) + '원';
|
||
$('a_hold').textContent = s.avg_hold_min + '분';
|
||
|
||
// 운용한도 대비 수익률 — 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(`📌 보유중 <b>${openCnt}</b>건 — 매수만 된 포지션(노란 행), 매도 후 trade_history 로 이동`);
|
||
}
|
||
if (filtered > 0) {
|
||
parts.push(`⚠️ 집계 제외: 0원 강제정리 <b>${filtered}</b>건`);
|
||
}
|
||
if (s.peak_cum_pnl > 0 && s.total_pnl < s.peak_cum_pnl) {
|
||
parts.push(
|
||
`💡 장중 누적이 <b>+${fmtKrw(s.peak_cum_pnl)}</b>까지 갔다가 이후 청산으로 <b>${fmtKrw(s.total_pnl)}</b> — `
|
||
+ `누적손익 열은 <b>매도 완료 순</b> 합계입니다.`,
|
||
);
|
||
}
|
||
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('<br>');
|
||
} else {
|
||
note.style.display = 'none';
|
||
note.innerHTML = '';
|
||
}
|
||
}
|
||
if (filtered > 0) {
|
||
console.log(`[실거래 분석] 0원 강제정리 ${filtered}건 제외 후 집계`);
|
||
}
|
||
|
||
// 승/패 바
|
||
if (s.total_trades > 0) {
|
||
$('act_winbar_card').style.display = '';
|
||
const wr = s.win_rate;
|
||
$('a_win_label').textContent = `🟢 승 ${s.win_trades}건 (${wr}%)`;
|
||
$('a_loss_label').textContent = `🔴 패 ${s.loss_trades}건 (${(100-wr).toFixed(1)}%)`;
|
||
$('a_ratio_g').style.width = wr + '%';
|
||
$('a_ratio_r').style.width = (100 - wr) + '%';
|
||
}
|
||
|
||
// 누적 손익 곡선
|
||
const eqLabels = d.equity.map((e,i) => i % Math.max(1, Math.floor(d.equity.length/20)) === 0 ? e.date : '');
|
||
lineChart('a_equity_chart', d.equity.map(e => e.date), d.equity.map(e => e.cum_pnl), '누적손익');
|
||
|
||
// 매도이유 도넛
|
||
const rKeys = Object.keys(d.reasons);
|
||
doughnutChart('a_reason_chart', rKeys, rKeys.map(k => d.reasons[k]));
|
||
|
||
// 종목별 손익 바
|
||
barChart('a_code_chart',
|
||
d.top_codes.map(c => c.name || c.code),
|
||
d.top_codes.map(c => c.pnl));
|
||
|
||
// 일별 바
|
||
barChart('a_daily_chart',
|
||
d.daily.map(e => e.date.slice(5)),
|
||
d.daily.map(e => e.pnl));
|
||
|
||
renderVirtualTrades('act_tbody', d.trades || [], {
|
||
showDebug: true,
|
||
showCumulative: true,
|
||
totalBudget: Number(p.total_budget_krw || 0),
|
||
});
|
||
}
|
||
|
||
// ────────────────────────────────────────────
|
||
// 돌파 백테스트 실행
|
||
// ────────────────────────────────────────────
|
||
function runBreakoutBacktest() {
|
||
let start = $('bo_start')?.value || '';
|
||
let end = $('bo_end')?.value || '';
|
||
if (!start) {
|
||
start = kstMonthStartIso();
|
||
setDateVal('bo_start', start);
|
||
}
|
||
if (!end) {
|
||
end = kstTodayParts().iso;
|
||
setDateVal('bo_end', end);
|
||
}
|
||
if (end && start && end < start) {
|
||
alert('종료일이 시작일보다 앞섭니다. 날짜를 확인하세요.');
|
||
return;
|
||
}
|
||
const params = {
|
||
start,
|
||
end,
|
||
lookback_min: $('bo_lookback').value,
|
||
vol_window: $('bo_vol_win').value,
|
||
vol_mult: $('bo_vol_mult').value,
|
||
min_turnover_1m_pct: $('bo_min_turnover')?.value || '0.05',
|
||
prev_chg_min: $('bo_prev_min').value,
|
||
prev_chg_max: $('bo_prev_max').value,
|
||
entry_mode: $('bo_entry_mode')?.value || 'intrabar',
|
||
intrabar_slippage_pct: $('bo_intrabar_slip')?.value || '0',
|
||
sl_mode: ($('bo_sl_mode') && $('bo_sl_mode').value) || 'fixed',
|
||
sl_pct: $('bo_sl').value,
|
||
atr_period: $('bo_atr_period')?.value || '14',
|
||
atr_sl_mult: $('bo_atr_sl_mult')?.value || '2.0',
|
||
atr_sl_min_pct: $('bo_atr_sl_min')?.value || '0.8',
|
||
atr_sl_max_pct: $('bo_atr_sl_max')?.value || '6.0',
|
||
tp_pct: $('bo_tp').value,
|
||
trail_pct: $('bo_trail').value,
|
||
trail_arm_pct: $('bo_trail_arm')?.value || '0',
|
||
shoulder_min_high_pct: $('bo_shoulder_smin').value,
|
||
shoulder_cut_pct: $('bo_shoulder_scut').value,
|
||
time_start_hm: $('bo_time_start').value,
|
||
time_end_hm: $('bo_time_end').value,
|
||
max_daily: $('bo_max_daily').value,
|
||
cooldown_min: $('bo_cooldown').value,
|
||
max_daily_chg: $('bo_max_chg').value,
|
||
min_price: $('bo_min_price').value,
|
||
confirm_margin_pct: $('bo_confirm_margin')?.value || '0',
|
||
body_min_pct: $('bo_body_min')?.value || '0',
|
||
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',
|
||
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,
|
||
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),
|
||
skip_hts_scan_dupes: !!($('bo_skip_hts_dupes')?.checked),
|
||
};
|
||
if (!confirm('💾 돌파 봇(BREAKOUT_* env)에 현재 탭 값을 저장할까요?\n실행 중이면 다음 루프부터 반영됩니다.\n다단트레일: ' + (body.daily_profit_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 =
|
||
`<span class="badge ${p.universe_source === 'history' ? 'badge-info' : 'badge-secondary'}">${universeLabel}</span> ` +
|
||
`진입 ${(p.entry_mode || 'intrabar').toUpperCase()} | ` +
|
||
`Lookback ${p.lookback_min}분 | 회전율≥${tr(p.min_turnover_1m_pct)}%` +
|
||
(Number(p.vol_mult) > 0 ? ` · VolWin${p.vol_window}분×${tr(p.vol_mult)}배` : '') + ` | ` +
|
||
`직전봉 ${tr(p.prev_chg_min)}~${tr(p.prev_chg_max)}% | ` +
|
||
((Number(p.confirm_margin_pct) > 0 || Number(p.body_min_pct) > 0)
|
||
? `가짜돌파필터(여유${tr(p.confirm_margin_pct||0)}%·몸통${tr(p.body_min_pct||0)}%) | ` : ``) +
|
||
`EMA필터 ${p.use_ema_filter ? `ON(${p.ema_fast_period}/${p.ema_slow_period})` : 'OFF'} | ` +
|
||
`손절-${tr(p.sl_pct)}% 익절+${tr(p.tp_pct)}% 트레일${tr(p.trail_pct)}%` +
|
||
(Number(p.trail_arm_pct) > 0 ? `(무장${tr(p.trail_arm_pct)}%)` : '') + ` | ` +
|
||
`${p.time_window}${periodHint} | 일${p.max_daily}회 쿨다운${p.cooldown_min}분 | ` +
|
||
`1회 ${Number(p.slot_money||0).toLocaleString()}원 · 동시${p.max_stocks||'?'}종 · 한도 ${Number(p.total_budget_krw||0).toLocaleString()}원 | 종목수 ${p.codes_analyzed || 0}개`;
|
||
let barHtml = summaryLine;
|
||
if (s.budget_warning) {
|
||
barHtml += `<div class="mt-1" style="color:#e3b341;font-size:12px">💰 ${s.budget_warning}</div>`;
|
||
}
|
||
const tickMeta = s.tick_backtest || {};
|
||
const buySrc = s.backtest_buy_source || '';
|
||
if (buySrc === 'ws_ticks' || buySrc === 'ohlc_fallback' || tickMeta.tick_bar_coverage_pct != null) {
|
||
const 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 += `<div class="mt-1" style="color:${tickColor};font-size:12px">📊 매수재생 ${srcLabel} | 틱 ${rows}건 · 분봉커버 ${covLabel}</div>`;
|
||
}
|
||
if ((s.total_trades || 0) === 0) {
|
||
const zmsg = s.budget_warning
|
||
? `거래 0건 — ${s.budget_warning}`
|
||
: '거래 0건 — 기간·유니버스·진입조건을 확인하세요. (1회투자 > 총한도 이면 체결 불가)';
|
||
barHtml += `<div class="mt-2 p-2 rounded" style="color:#f85149;font-size:13px;border:1px solid #f85149;background:rgba(248,81,73,.08)">⚠️ ${zmsg}</div>`;
|
||
}
|
||
if (boPb) boPb.innerHTML = barHtml;
|
||
const boCtx = $('bo_trade_context');
|
||
if (boCtx) {
|
||
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'),
|
||
mom_time_end: v('mom_time_end_buy'),
|
||
mom_max_from_open_pct: v('mom_max_from_open') || '999',
|
||
mom_min_from_open_pct: v('mom_min_from_open') || '-999',
|
||
universe: $('mom_use_univ_history')?.checked ? 'history' : 'sim',
|
||
daily_trail_tiers: (v('mom_daily_trail_tiers') || '').trim(),
|
||
daily_trail_drop_pct: v('mom_daily_trail_drop') || '0',
|
||
daily_trail_arm_krw: v('mom_daily_trail_arm') || '0',
|
||
daily_profit_mode: ($('mom_daily_profit_mode') && $('mom_daily_profit_mode').value) || 'trailing',
|
||
daily_profit_enabled: $('mom_daily_profit_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);
|
||
}
|
||
}
|
||
|
||
function saveMomentumConfig() {
|
||
const body = {
|
||
sl_pct: parseFloat($('mom_sl').value),
|
||
tp_pct: parseFloat($('mom_tp').value),
|
||
tp_max_pct: parseFloat($('mom_tp_max').value),
|
||
slot_money: parseInt($('mom_slot_money').value, 10) || 0,
|
||
slots: parseInt($('mom_slots').value, 10) || 3,
|
||
total_budget_krw: parseInt($('mom_total_budget')?.value || '0', 10),
|
||
cooldown_min: parseFloat($('mom_cooldown').value),
|
||
shoulder_min_high: parseFloat($('mom_smin').value),
|
||
shoulder_cut_pct: parseFloat($('mom_scut').value),
|
||
trail_pct: parseFloat($('mom_trail')?.value || '0'),
|
||
trail_arm_pct: parseFloat($('mom_trail_arm')?.value || '0'),
|
||
max_hold_bars: parseInt($('mom_max_hold')?.value || '0', 10),
|
||
ratchet_tiers: ($('mom_ratchet')?.value || '').trim(),
|
||
time_start: parseInt($('mom_time_start').value, 10),
|
||
max_daily: parseInt($('mom_max_daily').value, 10),
|
||
high_chase_thr: parseFloat($('mom_high_chase').value),
|
||
max_daily_chg: parseFloat($('mom_max_daily_chg').value),
|
||
min_price: parseFloat($('mom_min_price').value),
|
||
max_loss_krw: parseInt($('mom_max_loss_krw').value, 10),
|
||
min_margin: parseFloat($('mom_min_margin').value),
|
||
use_defense_filters: !!($('mom_use_defense')?.checked),
|
||
use_high_chase_filter: !!($('mom_use_high_chase_f')?.checked),
|
||
use_daily_range_filter: !!($('mom_use_daily_range')?.checked),
|
||
use_ema_filter: !!($('mom_use_ema_f')?.checked),
|
||
use_rsi_max_filter: !!($('mom_use_rsi_max_f')?.checked),
|
||
pattern_breakout: !!($('mom_pat_breakout')?.checked),
|
||
pattern_pullback: !!($('mom_pat_pullback')?.checked),
|
||
chase_lookback_min: parseInt($('mom_chase_lookback')?.value || '10', 10),
|
||
pullback_lookback_min: parseInt($('mom_pullback_lookback')?.value || '15', 10),
|
||
pullback_min_pct: parseFloat($('mom_pullback_min')?.value || '0.3'),
|
||
pullback_max_pct: parseFloat($('mom_pullback_max')?.value || '3.0'),
|
||
setup_vol_max_mult: parseFloat($('mom_setup_vol')?.value || '0.8'),
|
||
setup_bear_bars_min: parseInt($('mom_setup_bear')?.value || '1', 10),
|
||
ema_fast_period: parseInt($('mom_ema_fast')?.value || '9', 10),
|
||
ema_slow_period: parseInt($('mom_ema_slow')?.value || '21', 10),
|
||
mom_rsi_min: parseFloat($('mom_rsi_min').value),
|
||
mom_rsi_max: parseFloat($('mom_rsi_max').value),
|
||
mom_vol_mult: parseFloat($('mom_vol_mult').value),
|
||
mom_vol_win: parseInt($('mom_vol_win').value, 10),
|
||
mom_time_end: parseInt($('mom_time_end_buy').value, 10),
|
||
mom_max_from_open_pct: parseFloat($('mom_max_from_open')?.value || '999'),
|
||
mom_min_from_open_pct: parseFloat($('mom_min_from_open')?.value || '-999'),
|
||
ob_filter: !!($('mom_ob_filter')?.checked),
|
||
pg_filter: !!($('mom_pg_filter')?.checked),
|
||
daily_trail_tiers: ($('mom_daily_trail_tiers') && $('mom_daily_trail_tiers').value.trim()) || '',
|
||
daily_profit_mode: ($('mom_daily_profit_mode') && $('mom_daily_profit_mode').value) || 'trailing',
|
||
daily_profit_enabled: !!($('mom_daily_profit_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'))) return;
|
||
fetch('/api/backtest/momentum/save_config', {
|
||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify(body),
|
||
}).then(r => r.json()).then(d => {
|
||
if (d.error) { alert('❌ ' + d.error); return; }
|
||
btAddPresetOption('mom_daily_trail_preset', $('mom_daily_trail_tiers') && $('mom_daily_trail_tiers').value);
|
||
const byTbl = d.saved_by_table || {};
|
||
const tblLines = Object.entries(byTbl).map(([t, ks]) => ` ${t}: ${ks.join(', ')}`).join('\n');
|
||
alert(`✅ 저장 완료 (env_id: ${d.env_id})\n테이블:\n${tblLines || d.saved_keys?.join(', ')}`);
|
||
}).catch(e => alert('오류: ' + e));
|
||
}
|
||
|
||
function renderMomentumBacktest(d) {
|
||
if (!d || d.error) {
|
||
alert('❌ ' + (d && d.error ? d.error : '백테스트 응답 없음'));
|
||
return;
|
||
}
|
||
if (!d.summary) {
|
||
alert('백테스트 응답 오류 (summary 없음)');
|
||
return;
|
||
}
|
||
const s = d.summary || {};
|
||
const trades = d.trades || [];
|
||
const p = d.params || {};
|
||
|
||
const momRa = $('mom_result_area');
|
||
if (momRa) momRa.style.display = 'block';
|
||
const momPb = $('mom_params_bar');
|
||
if (momPb) momPb.style.display = 'block';
|
||
|
||
const setTxt = (id, txt) => { const el = $(id); if (el) el.textContent = txt; };
|
||
const momUnivLabel = formatUniverseLabel(p);
|
||
const effTp = p.effective_tp_pct != null ? p.effective_tp_pct : p.tp_pct;
|
||
const periodHint = (p.start && p.end) ? ` | ${p.start}~${p.end}` : '';
|
||
const patParts = [];
|
||
if (p.pattern_breakout) patParts.push('돌파');
|
||
if (p.pattern_pullback) patParts.push('눌림');
|
||
const patLabel = patParts.length ? patParts.join('+') : '패턴OFF';
|
||
const rsiLabel = p.use_rsi_max_filter
|
||
? `RSI ${p.mom_rsi_min}~${p.mom_rsi_max}`
|
||
: `RSI≥${p.mom_rsi_min}`;
|
||
const momSummaryLine =
|
||
`<span class="badge ${(p.universe_source === 'history' || p.universe_source === 'history_strict') ? 'badge-info' : 'badge-secondary'}" title="target_candidates_history">${momUnivLabel}</span> ` +
|
||
`${patLabel}(${p.chase_lookback_min || '—'}분) | ${rsiLabel} | 거래량×${p.mom_vol_mult} | ` +
|
||
`손절-${p.sl_pct}% | 어깨(${p.shoulder_min_high}%/${p.shoulder_cut_pct}%)` +
|
||
`${p.ratchet_tiers ? '·래칫' + p.ratchet_tiers : ''} | ` +
|
||
`익절상한+${effTp}%${periodHint} | ` +
|
||
`EMA ${p.use_ema_filter ? `ON(${p.ema_fast_period}/${p.ema_slow_period})` : 'OFF'} | ` +
|
||
`1회 ${Number(p.slot_money||0).toLocaleString()}원 · 동시${p.max_stocks||'?'}종 · 한도 ${Number(p.total_budget_krw||0).toLocaleString()}원 | ` +
|
||
`쿨다운${p.cooldown_min}분 | 일${p.max_daily || '—'}회 | 종목수 ${p.codes_analyzed || '—'}개`;
|
||
|
||
if (momPb) momPb.innerHTML = momSummaryLine;
|
||
const momCtx = $('mom_trade_context');
|
||
if (momCtx) {
|
||
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 += `<div class="mt-1" style="color:#e3b341;font-size:12px">💰 ${s.budget_warning}</div>`;
|
||
if (momCtx) momCtx.innerHTML += `<div style="color:#e3b341;font-size:12px">💰 ${s.budget_warning}</div>`;
|
||
}
|
||
if ((s.total_trades || 0) === 0) {
|
||
const zmsg = s.budget_warning
|
||
? `거래 0건 — ${s.budget_warning}`
|
||
: '거래 0건 — 기간·유니버스·진입조건을 확인하세요. (총한도 < 동시×1회투자 이면 체결 불가)';
|
||
const zhtml = `<div class="mt-2 p-2 rounded" style="color:#f85149;font-size:13px;border:1px solid #f85149;background:rgba(248,81,73,.08)">⚠️ ${zmsg}</div>`;
|
||
if (momPb) momPb.innerHTML += zhtml;
|
||
if (momCtx) momCtx.innerHTML += zhtml;
|
||
}
|
||
if (s.universe_warning) {
|
||
if (momPb) momPb.innerHTML += `<div class="mt-1" style="color:#f85149;font-size:12px">⚠️ ${s.universe_warning}</div>`;
|
||
if (momCtx) momCtx.innerHTML += `<div style="color:#f85149;font-size:12px">⚠️ ${s.universe_warning}</div>`;
|
||
}
|
||
const tickMeta = s.tick_backtest || {};
|
||
const skipStats = s.skip_stats || {};
|
||
if (tickMeta.tick_bar_coverage_pct != null || skipStats.tick_exit_count != null) {
|
||
const 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 += `<div class="mt-1" style="color:#58a6ff;font-size:12px">${tickLine}</div>`;
|
||
if (momCtx) momCtx.innerHTML += `<div style="color:#58a6ff;font-size:12px">${tickLine}</div>`;
|
||
}
|
||
if (skipStats.buy_queue_mode) {
|
||
const qm = skipStats.buy_queue_mode === 'live_scan'
|
||
? `매수큐 실매형 ${skipStats.scan_sec || 10}초 · 스캔${skipStats.scan_events ?? '—'} · 매수${skipStats.scan_buys ?? '—'}`
|
||
: '매수큐 분봉(레거시)';
|
||
const um = skipStats.universe_mode === 'scan_at'
|
||
? `유니버스 스캔시각·디바운스${skipStats.universe_debounce_sec ?? 30}초`
|
||
: (skipStats.universe_mode === 'minute_slot' ? '유니버스 분슬롯' : '');
|
||
const line = um ? `🔄 ${qm} | ${um}` : `🔄 ${qm}`;
|
||
if (momPb) momPb.innerHTML += `<div class="mt-1" style="color:#8b949e;font-size:12px">${line}</div>`;
|
||
if (momCtx) momCtx.innerHTML += `<div style="color:#8b949e;font-size:12px">${line}</div>`;
|
||
}
|
||
const peakCum = Number(s.peak_cum_pnl || 0);
|
||
const peakAt = fmtTradeTime(s.peak_cum_at || '');
|
||
if (peakCum > 0 && peakAt) {
|
||
const peakLine =
|
||
`💡 장중 누적 최고 <b style="color:var(--green)">+${peakCum.toLocaleString()}원</b> (${peakAt})` +
|
||
` · 최종 <b>${fmtKrw(s.total_pnl)}</b>`;
|
||
if (momPb) momPb.innerHTML += `<div class="mt-1" style="color:#8b949e;font-size:12px">${peakLine}</div>`;
|
||
}
|
||
|
||
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 ($('tl_daily_trail_tiers')) {
|
||
$('tl_daily_trail_tiers').value = (t.daily_trail_tiers != null ? String(t.daily_trail_tiers) : '');
|
||
}
|
||
if (t.daily_profit_mode && $('tl_daily_profit_mode')) {
|
||
$('tl_daily_profit_mode').value = t.daily_profit_mode;
|
||
}
|
||
// DB(env)에 저장된 사용자 프리셋 목록(세미콜론) → 드롭다운 ★옵션 복원
|
||
btFillPresetOptions('tl_ratchet_preset', t.ratchet_presets);
|
||
btFillPresetOptions('tl_daily_trail_preset', t.daily_trail_presets);
|
||
tlSyncRatchetShoulderColors();
|
||
}
|
||
|
||
// 파람서치 그리드 프리셋 드롭다운 → 텍스트 입력칸 채우기.
|
||
// '__custom__'(직접입력)은 무시, 'off'는 빈칸(=단일 어깨/미사용)으로 채운다.
|
||
function btApplyPreset(targetId, val, syncColors) {
|
||
if (val === '__custom__') return;
|
||
const el = document.getElementById(targetId);
|
||
if (!el) return;
|
||
el.value = (val === 'off') ? '' : val;
|
||
if (syncColors && typeof tlSyncRatchetShoulderColors === 'function') {
|
||
tlSyncRatchetShoulderColors();
|
||
}
|
||
}
|
||
|
||
// ── 커스텀 프리셋(직접입력값) 드롭다운 ─ DB(env) 영구 저장본 기반 ──────────
|
||
// 봇에 설정저장하면 서버가 BT_RATCHET_PRESETS / BT_DAILY_TRAIL_PRESETS(세미콜론 목록)에
|
||
// 값을 누적 저장하고, 페이지 로드 시 /api/env/params 응답으로 내려준다.
|
||
// 여기서는 그 목록을 드롭다운에 ★옵션으로 채우는 일만 한다.
|
||
|
||
// 드롭다운에 값 하나를 ★옵션으로 추가(중복·빈값 제외)
|
||
function btAddPresetOption(selId, val) {
|
||
const sel = document.getElementById(selId);
|
||
if (!sel) return;
|
||
const v = String(val || '').trim();
|
||
if (!v) return;
|
||
for (const opt of sel.options) { if (opt.value === v) return; }
|
||
const o = document.createElement('option');
|
||
o.value = v; o.textContent = '★ ' + v;
|
||
sel.appendChild(o);
|
||
}
|
||
|
||
// 세미콜론 구분 목록 문자열을 드롭다운에 일괄 채움
|
||
function btFillPresetOptions(selId, listStr) {
|
||
if (!listStr) return;
|
||
for (const v of String(listStr).split(';')) {
|
||
btAddPresetOption(selId, v);
|
||
}
|
||
}
|
||
|
||
// 돌파 손절모드 색상 — sl_mode=fixed vs atr 배타. atr ON 이면 고정 손절(%) 빨강(무시), atr OFF 이면 ATR 필드 빨강.
|
||
function boSyncSlModeColors() {
|
||
const modeEl = $('bo_sl_mode');
|
||
if (!modeEl) return;
|
||
const slEl = $('bo_sl');
|
||
const atrIds = ['bo_atr_period', 'bo_atr_sl_mult', 'bo_atr_sl_min', 'bo_atr_sl_max'];
|
||
const atrOn = String(modeEl.value || 'fixed').toLowerCase() === 'atr';
|
||
const RED = '#f85149';
|
||
const paint = (el, inactive) => {
|
||
if (!el) return;
|
||
if (inactive) {
|
||
el.style.setProperty('border-color', RED, 'important');
|
||
el.style.setProperty('background-color', 'rgba(248,81,73,0.12)', 'important');
|
||
el.style.setProperty('box-shadow', '0 0 0 1px ' + RED, 'important');
|
||
} else {
|
||
el.style.removeProperty('border-color');
|
||
el.style.removeProperty('background-color');
|
||
el.style.removeProperty('box-shadow');
|
||
}
|
||
};
|
||
paint(slEl, atrOn);
|
||
atrIds.forEach(id => paint($(id), !atrOn));
|
||
}
|
||
|
||
// 래칫/어깨컷 입력칸 색상 토글 — 청산 엔진은 둘이 배타(if/else)다.
|
||
// 래칫 값이 있으면 어깨컷(어깨발동·어깨폭)은 실행 안 됨 → 어깨컷 칸을 빨강(무시됨 표시).
|
||
// 래칫이 비면 래칫 칸을 빨강(OFF), 어깨컷은 원래색(이때 어깨컷이 1순위 트레일).
|
||
function tlSyncRatchetShoulderColors() {
|
||
const r = $('tl_ratchet');
|
||
if (!r) return;
|
||
const smin = $('tl_smin');
|
||
const scut = $('tl_scut');
|
||
const ratchetOn = !!(r.value && String(r.value).trim());
|
||
const RED = '#f85149';
|
||
// CSS .form-control 가 border 를 !important 로 강제하므로 인라인도 important 로 줘야 이김
|
||
const paint = (el, on) => {
|
||
if (!el) return;
|
||
if (on) {
|
||
el.style.setProperty('border-color', RED, 'important');
|
||
el.style.setProperty('background-color', 'rgba(248,81,73,0.12)', 'important');
|
||
el.style.setProperty('box-shadow', '0 0 0 1px ' + RED, 'important');
|
||
} else {
|
||
el.style.removeProperty('border-color');
|
||
el.style.removeProperty('background-color');
|
||
el.style.removeProperty('box-shadow');
|
||
}
|
||
el.title = on
|
||
? (el === r
|
||
? '래칫 OFF — 현재 어깨컷이 1순위 트레일로 동작'
|
||
: '래칫 ON — 이 어깨컷 값은 무시됨(래칫이 1순위 트레일)')
|
||
: (el.getAttribute('data-orig-title') || el.title);
|
||
};
|
||
// 래칫 ON → 어깨컷 빨강 / 래칫 OFF → 래칫 빨강
|
||
paint(smin, ratchetOn);
|
||
paint(scut, ratchetOn);
|
||
paint(r, !ratchetOn);
|
||
}
|
||
|
||
function fillMomentumFormFromApi(m) {
|
||
if (!m || typeof m !== 'object') return;
|
||
const set = (id, val) => { if (val !== null && val !== undefined && $(id)) $(id).value = val; };
|
||
set('mom_rsi_min', m.mom_rsi_min);
|
||
set('mom_rsi_max', m.mom_rsi_max);
|
||
set('mom_vol_mult', m.mom_vol_mult);
|
||
set('mom_vol_win', m.mom_vol_win);
|
||
set('mom_time_end_buy', m.mom_time_end_hm);
|
||
set('mom_time_start', m.mom_time_start_hm);
|
||
if (m.eod_enabled !== undefined && $('mom_eod_enabled')) {
|
||
$('mom_eod_enabled').checked = !!m.eod_enabled;
|
||
}
|
||
if (m.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 ($('mom_daily_trail_tiers')) {
|
||
$('mom_daily_trail_tiers').value = (m.daily_trail_tiers != null ? String(m.daily_trail_tiers) : '');
|
||
}
|
||
if (m.daily_profit_mode && $('mom_daily_profit_mode')) {
|
||
$('mom_daily_profit_mode').value = m.daily_profit_mode;
|
||
}
|
||
btFillPresetOptions('mom_daily_trail_preset', m.daily_trail_presets);
|
||
// 체크값 = 모멘텀 실효값(전용 우선·없으면 글로벌 상속). 밑에 글로벌/실매 출처를 표시.
|
||
const filterNote = (en, glob, explicit) => {
|
||
const onoff = v => (v ? 'ON' : 'OFF');
|
||
const src = explicit
|
||
? '모멘텀 전용 설정값'
|
||
: `글로벌 상속(전용 미설정)`;
|
||
// 실매도 동일 규칙(orderbook_filter.orderbook_filter_enabled) → 실매 적용값 = 실효값
|
||
return `글로벌 ${onoff(glob)} · 실매 ${onoff(en)} · ${src}`;
|
||
};
|
||
if (m.ob_filter_enabled !== undefined && $('mom_ob_filter')) {
|
||
$('mom_ob_filter').checked = !!m.ob_filter_enabled;
|
||
if ($('mom_ob_filter_note')) {
|
||
$('mom_ob_filter_note').textContent = filterNote(
|
||
!!m.ob_filter_enabled, !!m.ob_global_enabled, !!m.ob_strategy_explicit,
|
||
);
|
||
}
|
||
}
|
||
if (m.pg_filter_enabled !== undefined && $('mom_pg_filter')) {
|
||
$('mom_pg_filter').checked = !!m.pg_filter_enabled;
|
||
if ($('mom_pg_filter_note')) {
|
||
$('mom_pg_filter_note').textContent = filterNote(
|
||
!!m.pg_filter_enabled, !!m.pg_global_enabled, !!m.pg_strategy_explicit,
|
||
);
|
||
}
|
||
}
|
||
if (m.max_spread_pct != null) set('mom_max_spread_pct', m.max_spread_pct);
|
||
}
|
||
|
||
function loadMomentumSearchResults() {
|
||
showSpinner(true);
|
||
fetch('/api/backtest/momentum/search_results?top=30')
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
showSpinner(false);
|
||
if (d.error) { alert('❌ ' + d.error); return; }
|
||
momRenderParamSearch(d.top || [], d.meta || {});
|
||
})
|
||
.catch(err => { showSpinner(false); alert('오류: ' + err); });
|
||
}
|
||
|
||
function momApplySearchResult(rank, saveDb) {
|
||
showSpinner(true);
|
||
fetch('/api/backtest/momentum/apply_search', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({ rank, save_db: !!saveDb }),
|
||
})
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
showSpinner(false);
|
||
if (d.error) { alert('❌ ' + d.error); return; }
|
||
if (d.ui) fillMomentumFormFromApi(d.ui);
|
||
const m = d.metrics || {};
|
||
const msg = saveDb
|
||
? `✅ ${rank}위 → 폼 반영 + DB 저장 (env_id: ${d.env_id || '-'})\n손익 ${fmtKrw(m.total_pnl)} · 승률 ${m.win_rate}% · PF ${m.pf} · ${m.total_trades}건`
|
||
: `✅ ${rank}위 → 폼만 반영 (DB 미저장)\n손익 ${fmtKrw(m.total_pnl)} · 승률 ${m.win_rate}% · PF ${m.pf}`;
|
||
alert(msg);
|
||
})
|
||
.catch(err => { showSpinner(false); alert('오류: ' + err); });
|
||
}
|
||
|
||
function momRenderParamSearch(top, meta) {
|
||
window._mom_last_top = top || [];
|
||
const area = $('mom_search_result');
|
||
if (area) area.style.display = '';
|
||
const labelMap = {
|
||
mom_rsi_min: 'RSI min',
|
||
mom_rsi_max: 'RSI max',
|
||
mom_vol_mult: '거래량×',
|
||
mom_vol_win: '관찰봉',
|
||
sl_pct: '손절%',
|
||
tp_pct: '익절%',
|
||
tp_max_pct: '익절상한%',
|
||
shoulder_min_high: '어깨발동',
|
||
shoulder_cut_pct: '어깨컷',
|
||
trail_trigger: '트레일무장%',
|
||
trail_stop: '트레일%',
|
||
mom_max_from_open_pct: '시가+과열',
|
||
};
|
||
const pctKeys = new Set([
|
||
'sl_pct', 'tp_pct', 'tp_max_pct', 'shoulder_min_high', 'shoulder_cut_pct',
|
||
'trail_trigger', 'trail_stop', 'trail_pct', 'trail_arm_pct',
|
||
]);
|
||
const fixedLabel = (k) => labelMap[k] || k;
|
||
const keys = top && top.length ? Object.keys(top[0].params || {}) : [];
|
||
const thead = $('mom_search_thead');
|
||
if (thead) {
|
||
thead.innerHTML = '<tr><th>순위</th>' +
|
||
keys.map(k => `<th>${fixedLabel(k)}</th>`).join('') +
|
||
'<th>손익(원)</th><th>승률</th><th>거래</th><th>PF</th><th>보유(분)</th><th>폼반영</th><th>DB저장</th></tr>';
|
||
}
|
||
const tbody = $('mom_search_tbody');
|
||
if (!tbody) return;
|
||
tbody.innerHTML = '';
|
||
(top || []).forEach((r) => {
|
||
const p = r.params || {};
|
||
const pnlCls = r.total_pnl > 0 ? 'text-pnl-pos' : (r.total_pnl < 0 ? 'text-pnl-neg' : '');
|
||
const paramCells = keys.map(k => {
|
||
const v = p[k];
|
||
if (v === undefined || v === null) return '<td>-</td>';
|
||
if (pctKeys.has(k) && typeof v === 'number') return `<td>${Number(v).toFixed(2)}%</td>`;
|
||
return `<td>${v}</td>`;
|
||
}).join('');
|
||
const rank = r.rank;
|
||
tbody.insertAdjacentHTML('beforeend', `
|
||
<tr>
|
||
<td><b>${rank}</b></td>
|
||
${paramCells}
|
||
<td class="${pnlCls}">${fmtWon(r.total_pnl)}</td>
|
||
<td>${r.win_rate}%</td><td>${r.total_trades}</td>
|
||
<td>${r.pf}</td><td>${r.avg_hold_min != null ? r.avg_hold_min : '-'}</td>
|
||
<td><button class="btn btn-xs btn-outline-primary" style="font-size:11px;padding:1px 6px"
|
||
onclick="momApplySearchResult(${rank}, false)">폼</button></td>
|
||
<td><button class="btn btn-xs btn-outline-success" style="font-size:11px;padding:1px 6px"
|
||
onclick="momApplySearchResult(${rank}, true)">저장</button></td>
|
||
</tr>`);
|
||
});
|
||
const hint = $('mom_search_hint');
|
||
if (hint) {
|
||
hint.style.display = '';
|
||
const gh = meta.grid_axis_hints && typeof meta.grid_axis_hints === 'object'
|
||
? '<br>' + Object.entries(meta.grid_axis_hints).map(([k, txt]) =>
|
||
`<div style="margin:3px 0 0 8px;border-left:2px solid #30363d;padding-left:8px"><b>${fixedLabel(k)}</b> — ${txt}</div>`
|
||
).join('')
|
||
: '';
|
||
hint.innerHTML =
|
||
`<span style="color:var(--accent)">파일:</span> <code>${meta.path || '?'}</code> · `
|
||
+ `<b>${meta.start || '?'}</b> ~ <b>${meta.end || '?'}</b> · `
|
||
+ `모드 ${meta.mode || '-'} · 조합 ${meta.tested_combos || '-'} / ${meta.cartesian_product || '-'}<br>`
|
||
+ `<b>폼반영</b>: 입력란만 갱신 · <b>DB저장</b>: config_momentum MOMENTUM_* INSERT 후 폼 동기화`
|
||
+ ` (총손익≤0 은 거부).`
|
||
+ gh;
|
||
}
|
||
if (area) area.scrollIntoView({ behavior: 'smooth' });
|
||
}
|
||
|
||
function loadTailSearchResults() {
|
||
showSpinner(true);
|
||
fetch('/api/backtest/tail/search_results?top=30')
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
showSpinner(false);
|
||
if (d.error) { alert('❌ ' + d.error); return; }
|
||
tlRenderParamSearch(d.top || [], d.meta || {});
|
||
})
|
||
.catch(err => { showSpinner(false); alert('오류: ' + err); });
|
||
}
|
||
|
||
function tlApplySearchResult(rank, saveDb) {
|
||
showSpinner(true);
|
||
fetch('/api/backtest/tail/apply_search', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({ rank, save_db: !!saveDb }),
|
||
})
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
showSpinner(false);
|
||
if (d.error) { alert('❌ ' + d.error); return; }
|
||
if (d.ui) fillTailFormFromApi(d.ui);
|
||
const m = d.metrics || {};
|
||
const msg = saveDb
|
||
? `✅ ${rank}위 → 폼 반영 + DB 저장 (env_id: ${d.env_id || '-'})\n손익 ${fmtKrw(m.total_pnl)} · 승률 ${m.win_rate}% · PF ${m.pf} · ${m.total_trades}건`
|
||
: `✅ ${rank}위 → 폼만 반영 (DB 미저장)\n손익 ${fmtKrw(m.total_pnl)} · 승률 ${m.win_rate}% · PF ${m.pf}`;
|
||
alert(msg);
|
||
})
|
||
.catch(err => { showSpinner(false); alert('오류: ' + err); });
|
||
}
|
||
|
||
function tlRenderParamSearch(top, meta) {
|
||
window._tl_last_top = top || [];
|
||
$('tl_search_result').style.display = '';
|
||
const labelMap = {
|
||
max_daily_change: '급등컷%',
|
||
min_drop_rate: '낙폭',
|
||
min_recovery_ratio: '회복',
|
||
tail_ratio_min: '꼬리/몸통',
|
||
max_rec_3m: '3분회복',
|
||
shoulder_min_high: '어깨발동',
|
||
shoulder_cut_pct: '어깨컷',
|
||
stop_atr_mult: 'ATR손절',
|
||
target_atr_mult: 'ATR익절',
|
||
limit_atr_mult: '지정가ATR',
|
||
entry_mode: '진입모드',
|
||
trail_pct: '보조트레일%',
|
||
trail_arm_pct: '트레일무장%',
|
||
};
|
||
const fracKeys = new Set(['min_drop_rate', 'min_recovery_ratio', 'shoulder_min_high', 'shoulder_cut_pct']);
|
||
const ratioKeys = new Set(['max_rec_3m']);
|
||
const fixedLabel = (k) => labelMap[k] || k;
|
||
const keys = top && top.length ? Object.keys(top[0].params || {}) : [];
|
||
const thead = $('tl_search_thead');
|
||
thead.innerHTML = '<tr><th>순위</th>' +
|
||
keys.map(k => `<th>${fixedLabel(k)}</th>`).join('') +
|
||
'<th>손익(원)</th><th>승률</th><th>거래</th><th>PF</th><th>보유(분)</th><th>폼반영</th><th>DB저장</th></tr>';
|
||
const tbody = $('tl_search_tbody');
|
||
tbody.innerHTML = '';
|
||
(top || []).forEach((r) => {
|
||
const p = r.params || {};
|
||
const pnlCls = r.total_pnl > 0 ? 'text-pnl-pos' : (r.total_pnl < 0 ? 'text-pnl-neg' : '');
|
||
const paramCells = keys.map(k => {
|
||
const v = p[k];
|
||
if (v === undefined || v === null) return '<td>-</td>';
|
||
if (fracKeys.has(k)) return `<td>${(Number(v) * 100).toFixed(2)}%</td>`;
|
||
if (ratioKeys.has(k)) return `<td>${Number(v) <= 1 ? (Number(v) * 100).toFixed(0) : v}%</td>`;
|
||
return `<td>${v}</td>`;
|
||
}).join('');
|
||
const rank = r.rank;
|
||
tbody.insertAdjacentHTML('beforeend', `
|
||
<tr>
|
||
<td><b>${rank}</b></td>
|
||
${paramCells}
|
||
<td class="${pnlCls}">${fmtWon(r.total_pnl)}</td>
|
||
<td>${r.win_rate}%</td><td>${r.total_trades}</td>
|
||
<td>${r.pf}</td><td>${r.avg_hold_min != null ? r.avg_hold_min : '-'}</td>
|
||
<td><button class="btn btn-xs btn-outline-primary" style="font-size:11px;padding:1px 6px"
|
||
onclick="tlApplySearchResult(${rank}, false)">폼</button></td>
|
||
<td><button class="btn btn-xs btn-outline-success" style="font-size:11px;padding:1px 6px"
|
||
onclick="tlApplySearchResult(${rank}, true)">저장</button></td>
|
||
</tr>`);
|
||
});
|
||
const hint = $('tl_search_hint');
|
||
if (hint) {
|
||
hint.style.display = '';
|
||
const gh = meta.grid_axis_hints && typeof meta.grid_axis_hints === 'object'
|
||
? '<br>' + Object.entries(meta.grid_axis_hints).map(([k, txt]) =>
|
||
`<div style="margin:3px 0 0 8px;border-left:2px solid #30363d;padding-left:8px"><b>${fixedLabel(k)}</b> — ${txt}</div>`
|
||
).join('')
|
||
: '';
|
||
hint.innerHTML =
|
||
`<span style="color:var(--accent)">파일:</span> <code>${meta.path || '?'}</code> · `
|
||
+ `<b>${meta.start || '?'}</b> ~ <b>${meta.end || '?'}</b> · TF ${meta.timeframe || 3}분 · `
|
||
+ `조합 ${meta.tested_combos || '-'} / ${meta.cartesian_product || '-'}<br>`
|
||
+ `<b>폼반영</b>: 입력란만 갱신 · <b>DB저장</b>: config_short TAIL_* INSERT 후 폼 동기화`
|
||
+ ` (총손익≤0 은 거부). 승률·PF가 높은 조합은 순위와 다를 수 있음 — 표 <b>순위</b> 열 기준.`
|
||
+ gh;
|
||
}
|
||
$('tl_search_result').scrollIntoView({behavior: 'smooth'});
|
||
}
|
||
|
||
function saveTailConfig() {
|
||
const body = {
|
||
min_drop_rate: parseFloat($('tl_drop').value),
|
||
min_recovery_ratio: parseFloat($('tl_rec').value),
|
||
tail_ratio_min: parseFloat($('tl_tail').value),
|
||
tail_pct_min: parseFloat($('tl_tail_pct').value),
|
||
max_rec_3m: parseFloat($('tl_max_rec_3m').value),
|
||
sl_pct: parseFloat($('tl_sl').value),
|
||
tp_pct: parseFloat($('tl_tp').value),
|
||
shoulder_cut_pct: parseFloat($('tl_scut').value),
|
||
shoulder_min_high: parseFloat($('tl_smin').value),
|
||
high_chase_thr: parseFloat($('tl_high_chase').value),
|
||
cooldown_min: parseFloat($('tl_cool').value),
|
||
rsi_threshold: parseFloat($('tl_rsi').value),
|
||
rsi_period: parseInt($('tl_rsi_period').value, 10),
|
||
time_start: parseInt($('tl_ts').value, 10),
|
||
time_end: parseInt($('tl_te').value, 10),
|
||
max_daily: parseInt($('tl_maxd').value, 10),
|
||
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),
|
||
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'} | 다단규칙: ${body.daily_trail_tiers || '(미설정)'}\n\n` +
|
||
`저장: config_short (SHORT·tail_engine·tail_param_search 와 동일 키)`)) return;
|
||
fetch('/api/backtest/tail/save_config', {
|
||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify(body),
|
||
}).then(r => r.json()).then(d => {
|
||
if (d.error) { alert('❌ ' + d.error); return; }
|
||
// 서버가 DB(env)에 프리셋을 누적 저장했다. 재조회 없이 즉시 드롭다운에도 반영
|
||
btAddPresetOption('tl_ratchet_preset', $('tl_ratchet') && $('tl_ratchet').value);
|
||
btAddPresetOption('tl_daily_trail_preset', $('tl_daily_trail_tiers') && $('tl_daily_trail_tiers').value);
|
||
const tbl = d.saved_by_table ? Object.entries(d.saved_by_table).map(([k,v]) => k + ': ' + v.length + '키').join(', ') : '';
|
||
alert(`✅ 저장 완료 (env_id: ${d.env_id})\n${tbl}\n키: ${d.saved_keys?.join(', ')}`);
|
||
}).catch(e => alert('오류: ' + e));
|
||
}
|
||
|
||
// 유니버스 배지 문구 (저장 이력 / 시뮬 / 전종목)
|
||
function formatUniverseLabel(p) {
|
||
const histSrc = p.universe_source === 'history' || p.universe_source === 'history_strict';
|
||
if (histSrc && (p.universe_history_slots || 0) > 0) {
|
||
const strictTag = (p.universe_timing === 'strict' || p.universe_source === 'history_strict')
|
||
? '·strict' : '';
|
||
return `유니버스: 저장 이력 ${p.universe_history_slots}슬롯${strictTag}`;
|
||
}
|
||
if (p.universe_source === 'sim') return '유니버스: 시뮬레이션';
|
||
if (p.universe_source === 'all') return '유니버스: 전체 ws_candles 종목';
|
||
return '유니버스: 시뮬레이션(폴백)';
|
||
}
|
||
|
||
/** 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';
|
||
const qs = new URLSearchParams(params).toString();
|
||
showSpinner(true);
|
||
fetch('/api/backtest/scalping?' + qs)
|
||
.then(r => r.json())
|
||
.then(d => { showSpinner(false); renderBacktest(d); })
|
||
.catch(err => { showSpinner(false); alert('오류: ' + err); });
|
||
}
|
||
|
||
function renderBacktest(d) {
|
||
const s = d.summary;
|
||
const p = d.params;
|
||
|
||
$('bt_params_bar').style.display = '';
|
||
const universeLabel = formatUniverseLabel(p);
|
||
const tr = (v) => (typeof v === 'number' ? Number(v).toFixed(1) : v);
|
||
const btSummaryLine =
|
||
`<span class="badge ${p.universe_source === 'history' ? 'badge-info' : 'badge-secondary'}" title="target_candidates_history 사용 여부">${universeLabel}</span> ` +
|
||
`<span class="badge ${p.use_defense_filters ? 'badge-info' : 'badge-secondary'}">방어:${p.use_defense_filters ? 'ON' : 'OFF'}</span> ` +
|
||
`<span class="badge ${p.use_macd_cross ? 'badge-warning' : 'badge-secondary'}">MACD:${p.use_macd_cross ? 'ON' : 'OFF'}</span> ` +
|
||
`<span class="badge ${p.eod_enabled ? 'badge-info' : 'badge-secondary'}">EOD:${p.eod_enabled ? 'ON' : 'OFF'} ${p.eod_hm || ''}</span> ` +
|
||
`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 +=
|
||
`<div class="mt-1" style="color:${tickColor};font-size:12px">📊 매수재생 ${srcLabel} | 틱 ${rows}건 · 분봉커버 ${covLabel}</div>`;
|
||
}
|
||
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 =
|
||
`<span class="badge ${srcBadge}" title="dbband_stock_config">${p.name || d.code}(${p.code || d.code})</span> ` +
|
||
`BB ${p.bb_period}/${p.bb_inner_std}σ·${p.bb_outer_std}σ | ${trendLab} | ${exitLab} | ` +
|
||
`손절-${p.sl_pct}% 익절+${p.tp_pct}% (${tpLab}) | ` +
|
||
`어깨컷(${p.shoulder_min_high}%발동/${p.shoulder_cut_pct}%하락) | ` +
|
||
`유효${p.entry_valid_bars}봉·최대${p.max_hold_bars}봉${periodHint} | ` +
|
||
`1회 ${Number(p.slot_money || 0).toLocaleString()}원 · 동시${p.max_stocks || 1}종 · 한도 ${Number(p.total_budget_krw || 0).toLocaleString()}원 | ` +
|
||
`쿨다운${p.cooldown_min}분 | 일${p.max_daily || '—'}회 | ${d.tf || p.timeframe}분봉 ${d.candle_count || p.candle_count || '—'}봉 · ${srcLabel}`;
|
||
|
||
if (dbPb) dbPb.innerHTML = dbSummaryLine;
|
||
|
||
const psEl = $('db_param_source');
|
||
if (psEl) {
|
||
psEl.textContent = srcLabel;
|
||
psEl.className = 'badge ' + (p.param_source === 'dbband_stock_config' ? 'badge-info' : 'badge-secondary');
|
||
psEl.style.fontSize = '11px';
|
||
}
|
||
|
||
setTxt('db_total', (s.total_trades || 0) + '건');
|
||
setTxt('db_winrate', (s.win_rate || 0) + '%');
|
||
colorPnl($('db_winrate'), (s.win_rate || 0) - 50);
|
||
|
||
setTxt('db_pnl', fmtKrw(s.total_pnl));
|
||
colorPnl($('db_pnl'), s.total_pnl);
|
||
|
||
setTxt('db_pf', (s.profit_factor || 0) >= 999 ? '∞' : String(s.profit_factor || 0));
|
||
colorPnl($('db_pf'), (s.profit_factor || 0) - 1);
|
||
|
||
setTxt('db_mdd', '-' + fmtWon(s.max_drawdown) + '원');
|
||
setTxt('db_hold', (s.avg_hold_min || 0) + '분');
|
||
|
||
setTxt('db_bot_pct', sign(s.bot_pct || 0) + (s.bot_pct || 0) + '%');
|
||
colorPnl($('db_bot_pct'), s.bot_pct || 0);
|
||
setTxt('db_daily_avg_pct', sign(s.daily_avg_pct || 0) + (s.daily_avg_pct || 0) + '%');
|
||
colorPnl($('db_daily_avg_pct'), s.daily_avg_pct || 0);
|
||
|
||
setTxt('db_candles', d.candle_count != null ? d.candle_count : (p.candle_count || '-'));
|
||
|
||
if (s.budget_warning) {
|
||
if (dbPb) dbPb.innerHTML += `<div class="mt-1" style="color:#e3b341;font-size:12px">💰 ${s.budget_warning}</div>`;
|
||
}
|
||
if ((s.total_trades || 0) === 0) {
|
||
const zmsg = s.budget_warning
|
||
? `거래 0건 — ${s.budget_warning}`
|
||
: '거래 0건 — 기간·BB이탈·복귀 패턴·추세필터를 확인하세요.';
|
||
const zhtml = `<div class="mt-2 p-2 rounded" style="color:#f85149;font-size:13px;border:1px solid #f85149;background:rgba(248,81,73,.08)">⚠️ ${zmsg}</div>`;
|
||
if (dbPb) dbPb.innerHTML += zhtml;
|
||
}
|
||
|
||
const winbar = $('db_winbar_card');
|
||
if ((s.total_trades || 0) > 0) {
|
||
if (winbar) winbar.style.display = 'block';
|
||
const wr = s.win_rate || 0;
|
||
setTxt('db_win_label', `🟢 승 ${s.win_trades || 0}건 (${wr}%)`);
|
||
setTxt('db_loss_label', `🔴 패 ${s.loss_trades || 0}건 (${(100 - wr).toFixed(1)}%)`);
|
||
const rg = $('db_ratio_g'); if (rg) rg.style.width = wr + '%';
|
||
const rr = $('db_ratio_r'); if (rr) rr.style.width = (100 - wr) + '%';
|
||
} else if (winbar) {
|
||
winbar.style.display = 'none';
|
||
}
|
||
|
||
if (s.bnh_pct !== undefined && s.bnh_pct !== null) {
|
||
const bnhRow = $('db_bnh_row');
|
||
if (bnhRow) bnhRow.style.display = '';
|
||
setTxt('db_bot_pct_bnh', sign(s.bot_pct || 0) + (s.bot_pct || 0) + '%');
|
||
colorPnl($('db_bot_pct_bnh'), s.bot_pct || 0);
|
||
setTxt('db_bnh_pct', sign(s.bnh_pct) + s.bnh_pct + '%');
|
||
colorPnl($('db_bnh_pct'), s.bnh_pct);
|
||
setTxt('db_bnh_pnl', sign(s.bnh_pnl) + fmtKrw(s.bnh_pnl));
|
||
colorPnl($('db_bnh_pnl'), s.bnh_pnl);
|
||
setTxt('db_alpha', sign(s.alpha_pct) + (s.alpha_pct != null ? s.alpha_pct : 0) + '%p');
|
||
colorPnl($('db_alpha'), s.alpha_pct || 0);
|
||
} else {
|
||
const bnhRow = $('db_bnh_row');
|
||
if (bnhRow) bnhRow.style.display = 'none';
|
||
}
|
||
|
||
try {
|
||
if (d.equity && d.equity.length) {
|
||
lineChart('db_equity_chart',
|
||
d.equity.map(e => e.date),
|
||
d.equity.map(e => e.cum_pnl),
|
||
'가상누적손익', '#3fb950');
|
||
} else {
|
||
destroyChart('db_equity_chart');
|
||
}
|
||
|
||
const rKeys = Object.keys(d.reasons || {});
|
||
if (rKeys.length) {
|
||
doughnutChart('db_reason_chart', rKeys, rKeys.map(k => d.reasons[k]));
|
||
} else {
|
||
destroyChart('db_reason_chart');
|
||
}
|
||
} catch (chartErr) {
|
||
console.error('db chart render', chartErr);
|
||
}
|
||
|
||
const tbody = $('db_tbody');
|
||
if (tbody) {
|
||
tbody.innerHTML = '';
|
||
trades.forEach(t => {
|
||
const tr = document.createElement('tr');
|
||
const pnl = Number(t.pnl || 0);
|
||
tr.innerHTML =
|
||
`<td>${t.side || 'long'}</td><td>${t.entry_time || ''}</td><td>${t.exit_time || ''}</td>` +
|
||
`<td>${fmtWon(t.entry)}</td><td>${fmtWon(t.exit)}</td>` +
|
||
`<td class="${pnl >= 0 ? 'text-success' : 'text-danger'}">${fmtWon(pnl)}</td>` +
|
||
`<td>${t.hold_min || '-'}</td><td>${t.reason || ''}</td>`;
|
||
tbody.appendChild(tr);
|
||
});
|
||
}
|
||
|
||
if (dbRa) dbRa.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
}
|
||
|
||
function saveDbBandStockConfig() {
|
||
const code = $('db_code')?.value;
|
||
if (!code) { alert('종목을 선택하세요'); return; }
|
||
const meta = dbSelectedMeta();
|
||
const body = Object.assign(dbCollectFormBody(), { code }, meta);
|
||
if (!confirm(`${code} 파라미터를 dbband_stock_config에 저장할까요?`)) return;
|
||
fetch('/api/dbband/save_holding', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body),
|
||
})
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
if (d.error) alert('❌ ' + d.error);
|
||
else {
|
||
alert('✅ 저장 완료 (' + code + ', tf=' + d.dbband_tf_min + '분)');
|
||
dbLoadStocks();
|
||
}
|
||
})
|
||
.catch(err => alert('저장 오류: ' + err));
|
||
}
|
||
|
||
function dbApplySearchParams(p) {
|
||
if (!p) return;
|
||
dbFillFormFromEngine({
|
||
bb_period: p.bb_period,
|
||
bb_inner_std: p.bb_inner_std,
|
||
bb_outer_std: p.bb_outer_std,
|
||
trend_ma_period: p.trend_ma_period,
|
||
sl_pct: p.stop_loss_pct != null ? p.stop_loss_pct : (p.sl_pct != null ? p.sl_pct * 100 : null),
|
||
tp_pct: p.take_profit_pct != null ? p.take_profit_pct : (p.tp_pct != null ? p.tp_pct * 100 : null),
|
||
tp_mode: p.tp_mode,
|
||
rr_ratio: p.rr_ratio,
|
||
entry_valid_bars: p.entry_valid_bars,
|
||
max_hold_bars: p.max_hold_bars,
|
||
slot_money: p.slot_money,
|
||
use_trend_filter: p.use_trend_filter,
|
||
});
|
||
}
|
||
|
||
function runDbBandParamSearch() {
|
||
const code = $('db_code')?.value;
|
||
if (!code) { alert('종목을 선택하세요'); return; }
|
||
const qs = new URLSearchParams({
|
||
code,
|
||
start: $('db_start').value,
|
||
end: $('db_end').value,
|
||
tf: $('db_tf').value,
|
||
top: '20',
|
||
});
|
||
showSpinner(true);
|
||
fetch('/api/dbband/param_search?' + qs.toString())
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
showSpinner(false);
|
||
if (d.error) { alert(d.error); return; }
|
||
window._db_last_search = d.results || [];
|
||
$('db_search_results').style.display = '';
|
||
const tb = $('db_search_tbody');
|
||
tb.innerHTML = '';
|
||
(d.results || []).forEach((row, i) => {
|
||
const p = row.params || {};
|
||
const tr = document.createElement('tr');
|
||
tr.innerHTML =
|
||
`<td>${i + 1}</td><td>${fmtWon(row.total_pnl)}</td><td>${row.win_rate}%</td>` +
|
||
`<td>${row.total_trades}</td><td>${p.bb_inner_std}/${p.bb_outer_std}</td>` +
|
||
`<td>${p.trend_ma_period}</td>` +
|
||
`<td><button type="button" class="btn btn-sm btn-outline-primary">적용</button></td>`;
|
||
tr.querySelector('button').onclick = () => dbSaveSearchRow(row, i + 1);
|
||
tb.appendChild(tr);
|
||
});
|
||
})
|
||
.catch(err => { showSpinner(false); alert('오류: ' + err); });
|
||
}
|
||
|
||
function dbSaveSearchRow(row, rank) {
|
||
const code = $('db_code')?.value;
|
||
if (!code || !row) return;
|
||
const p = row.params || {};
|
||
dbApplySearchParams(p);
|
||
const meta = dbSelectedMeta();
|
||
const body = Object.assign(dbCollectFormBody(), meta, {
|
||
code,
|
||
bb_period: p.bb_period,
|
||
bb_inner_std: p.bb_inner_std,
|
||
bb_outer_std: p.bb_outer_std,
|
||
trend_ma_period: p.trend_ma_period,
|
||
stop_loss_pct: p.stop_loss_pct != null ? p.stop_loss_pct : (p.sl_pct != null ? p.sl_pct * 100 : undefined),
|
||
take_profit_pct: p.take_profit_pct != null ? p.take_profit_pct : (p.tp_pct != null ? p.tp_pct * 100 : undefined),
|
||
tp_mode: p.tp_mode,
|
||
rr_ratio: p.rr_ratio,
|
||
entry_valid_bars: p.entry_valid_bars,
|
||
max_hold_bars: p.max_hold_bars,
|
||
slot_money: p.slot_money != null ? p.slot_money : parseInt($('db_slot').value, 10),
|
||
use_trend_filter: p.use_trend_filter != null ? p.use_trend_filter : $('db_use_trend').checked,
|
||
});
|
||
if (!confirm(`#${rank} 파라미터를 ${code}에 저장할까요?`)) return;
|
||
fetch('/api/dbband/save_holding', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body),
|
||
})
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
if (d.error) alert('❌ ' + d.error);
|
||
else {
|
||
alert('✅ #' + rank + ' 적용·저장 완료');
|
||
dbLoadStocks();
|
||
}
|
||
})
|
||
.catch(err => alert('저장 오류: ' + err));
|
||
}
|
||
|
||
// ────────────────────────────────────────────
|
||
// 꼬리잡기 백테스트
|
||
// ────────────────────────────────────────────
|
||
function formatLimitEntryLabel(p) {
|
||
if (!p) return '';
|
||
const em = String(p.entry_mode || 'align').toLowerCase();
|
||
if (em === 'limit_atr') {
|
||
return ` · 진입 C limit_atr · ${p.limit_anchor || 'signal_low'} −ATR×${p.limit_atr_mult != null ? p.limit_atr_mult : '-'} · 유효${p.limit_valid_bars != null ? p.limit_valid_bars : 1}봉 · 슬립${p.limit_fill_slip_pct != null ? p.limit_fill_slip_pct : 0}%`;
|
||
}
|
||
return ' · 진입 A align(다음봉 시가)';
|
||
}
|
||
|
||
function runTailBacktest() {
|
||
const params = {
|
||
start: $('tl_start').value,
|
||
end: $('tl_end').value,
|
||
timeframe: $('tl_tf').value,
|
||
min_drop_rate: $('tl_drop').value,
|
||
min_recovery_ratio: $('tl_rec').value,
|
||
tail_ratio_min: $('tl_tail').value,
|
||
tail_pct_min: $('tl_tail_pct').value,
|
||
max_rec_3m: $('tl_max_rec_3m').value,
|
||
sl_pct: $('tl_sl').value,
|
||
tp_pct: $('tl_tp').value,
|
||
shoulder_min_high: $('tl_smin').value,
|
||
shoulder_cut_pct: $('tl_scut').value,
|
||
high_chase_thr: $('tl_high_chase').value,
|
||
rsi_period: $('tl_rsi_period').value,
|
||
rsi_threshold: $('tl_rsi').value,
|
||
cooldown_min: $('tl_cool').value,
|
||
time_start: $('tl_ts').value,
|
||
time_end: $('tl_te').value,
|
||
max_daily: $('tl_maxd').value,
|
||
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',
|
||
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,
|
||
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); });
|
||
}
|
||
|
||
function renderTailBacktest(d) {
|
||
const s = d.summary;
|
||
const p = d.params;
|
||
|
||
$('tl_params_bar').style.display = '';
|
||
const tlUniverseLabel = formatUniverseLabel(p);
|
||
const tlSummaryLine =
|
||
`<span class="badge ${p.universe_source === 'history' ? 'badge-info' : 'badge-secondary'}" title="target_candidates_history 사용 여부">${tlUniverseLabel}</span> ` +
|
||
(p.timeframe != null ? `<span class="badge badge-primary" title="ws_candles 분봉">${p.timeframe}분봉</span> ` : '') +
|
||
`낙폭≥${p.min_drop_rate}% | 회복률≥${p.min_recovery_ratio}% | 꼬리/몸통≥${p.tail_ratio_min} | ` +
|
||
`손절-${p.sl_pct}% 익절+${p.tp_pct}% | 어깨컷(${p.shoulder_min_high}%발동/${p.shoulder_cut_pct}%하락)` +
|
||
(Number(p.trail_pct) > 0 ? ` | 보조트레일${p.trail_pct}%` + (Number(p.trail_arm_pct) > 0 ? `(무장${p.trail_arm_pct}%)` : '') : '') + ` | ` +
|
||
(p.max_loss_krw != null ? `최대손실컷 ${Number(p.max_loss_krw).toLocaleString()}원 | ` : '') +
|
||
`1회투자 ${Number(p.slot_money||0).toLocaleString()}원 | 동시${p.max_stocks||3}종 | 총한도 ${Number(p.total_budget_krw||0).toLocaleString()}원 | ` +
|
||
`RSI(${p.rsi_period})과열<${p.rsi_threshold} | 쿨다운${p.cooldown_min}분 | ${p.time_window || '930-1500'} | 일${p.max_daily || 3}회/종목 | 종목수 ${p.codes_analyzed}개 | 시각순 포트폴리오` +
|
||
formatLimitEntryLabel(p);
|
||
$('tl_params_bar').innerHTML = tlSummaryLine;
|
||
const tlCtx = $('tl_trade_context');
|
||
if (tlCtx) {
|
||
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||0) >= 999 ? '∞' : (s.profit_factor||0);
|
||
colorPnl($('tl_pf'), (s.profit_factor||0) - 1);
|
||
|
||
$('tl_mdd').textContent = '-' + fmtWon(s.max_drawdown) + '원';
|
||
$('tl_hold').textContent = (s.avg_hold_min||0) + '분';
|
||
|
||
const tlSign = v => (v > 0 ? '+' : '');
|
||
$('tl_bot_pct').textContent = tlSign(s.bot_pct || 0) + (s.bot_pct || 0) + '%';
|
||
colorPnl($('tl_bot_pct'), s.bot_pct || 0);
|
||
$('tl_daily_avg_pct').textContent = tlSign(s.daily_avg_pct || 0) + (s.daily_avg_pct || 0) + '%';
|
||
colorPnl($('tl_daily_avg_pct'), s.daily_avg_pct || 0);
|
||
|
||
if (s.universe_warning) {
|
||
$('tl_params_bar').innerHTML += `<div class="mt-1" style="color:#f85149;font-size:12px">⚠️ ${s.universe_warning}</div>`;
|
||
if (tlCtx) tlCtx.innerHTML += `<div style="color:#f85149;font-size:12px">⚠️ ${s.universe_warning}</div>`;
|
||
}
|
||
if (s.budget_warning) {
|
||
$('tl_params_bar').innerHTML += `<div class="mt-1" style="color:#e3b341;font-size:12px">💰 ${s.budget_warning}</div>`;
|
||
if (tlCtx) tlCtx.innerHTML += `<div style="color:#e3b341;font-size:12px">💰 ${s.budget_warning}</div>`;
|
||
}
|
||
const tickMeta = s.tick_backtest || {};
|
||
const buySrc = s.backtest_buy_source || '';
|
||
if (buySrc === 'ws_ticks' || buySrc === 'ohlc_fallback' || tickMeta.tick_bar_coverage_pct != null) {
|
||
const 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 = `<div class="mt-1" style="color:${tickColor};font-size:12px">📊 매수재생 ${srcLabel} | 틱 ${rows}건 · 분봉커버 ${covLabel}</div>`;
|
||
$('tl_params_bar').innerHTML += tickLine;
|
||
if (tlCtx) tlCtx.innerHTML += tickLine;
|
||
const srcMap = s.tick_entry_sources || {};
|
||
const srcKeys = Object.keys(srcMap);
|
||
if (srcKeys.length) {
|
||
const srcDetail = srcKeys.map(k => `${k}:${srcMap[k]}`).join(' · ');
|
||
const srcLine = `<div style="color:#8b949e;font-size:11px">진입출처 ${srcDetail}</div>`;
|
||
$('tl_params_bar').innerHTML += srcLine;
|
||
if (tlCtx) tlCtx.innerHTML += srcLine;
|
||
}
|
||
}
|
||
|
||
// 매수큐 실매형 스캔주기(초)·유니버스 디바운스(초) — 모멘텀 동일 표기
|
||
const tlSkip = s.skip_stats || {};
|
||
if (tlSkip.buy_queue_mode) {
|
||
const qm = tlSkip.buy_queue_mode === 'live_scan'
|
||
? `매수큐 실매형 ${tlSkip.scan_sec || 10}초 · 스캔${tlSkip.scan_events ?? '—'} · 매수${tlSkip.scan_buys ?? '—'}`
|
||
: '매수큐 분봉(레거시)';
|
||
const um = tlSkip.universe_mode === 'scan_at'
|
||
? `유니버스 스캔시각·디바운스${tlSkip.universe_debounce_sec ?? 30}초`
|
||
: (tlSkip.universe_mode === 'minute_slot' ? '유니버스 분슬롯' : '');
|
||
const line = um ? `🔄 ${qm} | ${um}` : `🔄 ${qm}`;
|
||
$('tl_params_bar').innerHTML += `<div class="mt-1" style="color:#8b949e;font-size:12px">${line}</div>`;
|
||
if (tlCtx) tlCtx.innerHTML += `<div style="color:#8b949e;font-size:12px">${line}</div>`;
|
||
}
|
||
// 장중 누적손익 최고점 — 파라미터 바에 참고 표기 (거래내역 컨텍스트와 동일 수치)
|
||
const tlPeakCum = Number(s.peak_cum_pnl || 0);
|
||
const tlPeakAt = fmtTradeTime(s.peak_cum_at || '');
|
||
if (tlPeakCum > 0 && tlPeakAt) {
|
||
const peakLine =
|
||
`💡 장중 누적 최고 <b style="color:var(--green)">+${tlPeakCum.toLocaleString()}원</b> (${tlPeakAt})` +
|
||
` · 최종 <b>${fmtKrw(s.total_pnl)}</b>`;
|
||
$('tl_params_bar').innerHTML += `<div class="mt-1" style="color:#8b949e;font-size:12px">${peakLine}</div>`;
|
||
}
|
||
|
||
if ((s.total_trades||0) > 0) {
|
||
$('tl_winbar_card').style.display = '';
|
||
const wr = s.win_rate||0;
|
||
$('tl_win_label').textContent = `🟢 승 ${s.win_trades}건 (${wr}%)`;
|
||
$('tl_loss_label').textContent = `🔴 패 ${s.loss_trades}건 (${(100-wr).toFixed(1)}%)`;
|
||
$('tl_ratio_g').style.width = wr + '%';
|
||
$('tl_ratio_r').style.width = (100 - wr) + '%';
|
||
}
|
||
|
||
// 누적 손익 곡선
|
||
lineChart('tl_equity_chart',
|
||
d.equity.map(e => e.date),
|
||
d.equity.map(e => e.cum_pnl),
|
||
'가상누적손익', '#3fb950');
|
||
|
||
// 매도 이유 도넛
|
||
const rKeys = Object.keys(d.reasons || {});
|
||
doughnutChart('tl_reason_chart', rKeys, rKeys.map(k => d.reasons[k]));
|
||
|
||
// 일별 바
|
||
barChart('tl_daily_chart',
|
||
d.daily.map(e => e.date.slice(5)),
|
||
d.daily.map(e => e.pnl));
|
||
|
||
renderVirtualTrades('tl_tbody', d.trades || [], {
|
||
showDebug: true,
|
||
showCumulative: true,
|
||
totalBudget: Number((d.params && d.params.total_budget_krw) || 0),
|
||
});
|
||
}
|
||
|
||
// ────────────────────────────────────────────
|
||
// 홀딩 전략
|
||
// ────────────────────────────────────────────
|
||
let hdCurrentCode = null;
|
||
let hdCurrentName = null;
|
||
let hdCurrentCfg = {};
|
||
|
||
function hdLoadStocks() {
|
||
fetch('/api/holding/stocks')
|
||
.then(r => r.json())
|
||
.then(stocks => hdRenderStockList(stocks))
|
||
.catch(err => alert('종목 로드 실패: ' + err));
|
||
}
|
||
|
||
function hdRenderStockList(stocks) {
|
||
const list = $('hd_stock_list');
|
||
list.innerHTML = '';
|
||
stocks.forEach(s => {
|
||
const candleInfo = s.candle_count > 0
|
||
? `<span style="color:var(--green);font-size:11px">✅ 일봉 ${s.candle_count}봉 (${s.candle_min||'?'} ~ ${s.candle_max||'?'})</span>`
|
||
: `<span style="color:var(--red);font-size:11px">⚠️ 일봉 없음</span>`;
|
||
const min60Info = s.min60_count > 0
|
||
? `<span style="color:#58a6ff;font-size:11px">⏱ 60분봉 ${s.min60_count}봉 (${s.min60_min||'?'} ~ ${s.min60_max||'?'})</span>`
|
||
: `<span style="color:var(--muted);font-size:11px">⏱ 60분봉 없음</span>`;
|
||
list.insertAdjacentHTML('beforeend', `
|
||
<div class="col-12 col-md-6 col-lg-4">
|
||
<div class="card p-3">
|
||
<div class="d-flex justify-content-between align-items-start mb-1">
|
||
<div>
|
||
<b style="font-size:15px">${s.name||s.code}</b>
|
||
<span style="color:var(--muted);font-size:12px;margin-left:6px">${s.code}</span>
|
||
</div>
|
||
<div class="text-end">${candleInfo}<br>${min60Info}</div>
|
||
</div>
|
||
<!-- 파라미터 인풋 -->
|
||
<div class="row g-1" style="font-size:12px">
|
||
<div class="col-12" style="color:var(--accent);font-size:11px;font-weight:600;padding:2px 4px">▶ 추세 설정</div>
|
||
<div class="col-4"><label>MA단기 <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_ma_fast" value="${s.ma_fast||20}" min="5" max="60" step="1"></label></div>
|
||
<div class="col-4"><label>MA장기 <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_ma_slow" value="${s.ma_slow||60}" min="20" max="200" step="5"></label></div>
|
||
<div class="col-4"><label>트레일(%) <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_trail_stop_pct" value="${s.trail_stop_pct??8}" min="0" max="30" step="0.5" title="고점 대비 X% 하락 시 추세이탈 청산. 0=비활성"></label></div>
|
||
<div class="col-6"><label>추세필터
|
||
<select class="form-control form-control-sm" id="cfg_${s.code}_trend_filter">
|
||
<option value="1" ${(s.trend_filter??1)>=0.5?'selected':''}>ON (추세추종)</option>
|
||
<option value="0" ${(s.trend_filter??1)<0.5?'selected':''}>OFF (역추세)</option>
|
||
</select></label></div>
|
||
<div class="col-12" style="color:var(--muted);font-size:11px;font-weight:600;padding:2px 4px">▶ RSI 매수/매도</div>
|
||
<div class="col-6"><label>RSI기간 <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_rsi_period" value="${s.rsi_period||14}" min="5" max="30"></label></div>
|
||
<div class="col-6"><label>매수1 RSI≤ <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_rsi_buy1" value="${s.rsi_buy1||55}" min="10" max="70" step="1" title="추세 ON: 1차 눌림목 / 추세 OFF: 1단계"></label></div>
|
||
<div class="col-6"><label>매수2 RSI≤ <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_rsi_buy2" value="${s.rsi_buy2||45}" min="10" max="65" step="1" title="추세 ON: 2차 눌림목 / 추세 OFF: 2단계"></label></div>
|
||
<div class="col-6"><label>매수3 RSI≤ <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_rsi_buy3" value="${s.rsi_buy3||35}" min="5" max="55" step="1" title="추세 OFF 전용 3단계 (추세 ON에선 미사용)"></label></div>
|
||
<div class="col-6"><label>매도 RSI≥ <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_rsi_sell" value="${s.rsi_sell||75}" min="50" max="95" step="1"></label></div>
|
||
<div class="col-6"><label>익절(%) <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_take_profit_pct" value="${s.take_profit_pct||15}" min="1" max="50" step="1" title="추세장엔 크게 잡아야 탈 안 남"></label></div>
|
||
<div class="col-6"><label>손절(%) <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_stop_loss_pct" value="${s.stop_loss_pct||10}" min="2" max="30" step="0.5"></label></div>
|
||
<div class="col-6"><label>투자금(원) <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_slot_money" value="${s.slot_money||3000000}" min="500000" step="500000"></label></div>
|
||
<div class="col-12" style="color:var(--muted);font-size:11px;font-weight:600;padding:2px 4px">▶ 분할 비중(%) — 추세 ON이면 1·2차만 사용. 합 100% 권장 (탐색·추세BT·저장 동일)</div>
|
||
<div class="col-4"><label>비중1 <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_buy1_ratio" value="${s.buy1_ratio??50}" min="0" max="100" step="5"></label></div>
|
||
<div class="col-4"><label>비중2 <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_buy2_ratio" value="${s.buy2_ratio??50}" min="0" max="100" step="5"></label></div>
|
||
<div class="col-4"><label>비중3 <input type="number" class="form-control form-control-sm" id="cfg_${s.code}_buy3_ratio" value="${s.buy3_ratio??0}" min="0" max="100" step="5" title="추세 OFF(역추세) 3단계에만 사용"></label></div>
|
||
<div class="col-12" style="color:var(--muted);font-size:11px;font-weight:600;padding:2px 4px">▶ 샹들리에 엑시트 (추세장 청산 — 고점 − ATR × 배수)</div>
|
||
<div class="col-6"><label title="ATR 계산 기간 (기본 14일)">ATR기간
|
||
<input type="number" class="form-control form-control-sm" id="cfg_${s.code}_atr_period"
|
||
value="${s.atr_period||14}" min="5" max="30" step="1"></label></div>
|
||
<div class="col-6"><label title="샹들리에 배수. 2=타이트 / 3=기본 / 4=여유 (클수록 오래 홀딩)">샹들리에배수
|
||
<input type="number" class="form-control form-control-sm" id="cfg_${s.code}_atr_mult"
|
||
value="${s.atr_mult||3}" min="1" max="6" step="0.5"></label></div>
|
||
<div class="col-12" style="color:var(--muted);font-size:11px;font-weight:600;padding:2px 4px">▶ 낙폭 필터 (0=비활성 / 예: 20 → 고점 대비 20% 이상 빠진 날만 매수)</div>
|
||
<div class="col-4"><label title="역대 최고가(ATH) 대비 현재가 낙폭이 X% 이상일 때만 매수. 0=끔">ATH낙폭(%)
|
||
<input type="number" class="form-control form-control-sm" id="cfg_${s.code}_ath_drop_min_pct"
|
||
value="${s.ath_drop_min_pct||0}" min="0" max="70" step="5"></label></div>
|
||
<div class="col-4"><label title="당해연도 고점 대비 낙폭 필터. 0=끔">연도낙폭(%)
|
||
<input type="number" class="form-control form-control-sm" id="cfg_${s.code}_year_drop_min_pct"
|
||
value="${s.year_drop_min_pct||0}" min="0" max="70" step="5"></label></div>
|
||
<div class="col-4"><label title="52주 고점 대비 낙폭 필터. 0=끔">52주낙폭(%)
|
||
<input type="number" class="form-control form-control-sm" id="cfg_${s.code}_w52_drop_min_pct"
|
||
value="${s.w52_drop_min_pct||0}" min="0" max="70" step="5"></label></div>
|
||
</div>
|
||
<!-- 버튼 (일봉 + 키움 분봉 수집) -->
|
||
<div class="d-flex gap-1 mt-2 flex-wrap">
|
||
<button class="btn btn-sm btn-outline-secondary" onclick="hdFetchCandles('${s.code}','${s.name||s.code}')" title="KIS 일봉 수집">📥 일봉수집</button>
|
||
<button class="btn btn-sm btn-outline-secondary" style="color:#58a6ff;border-color:#58a6ff" onclick="hdFetchMinCandlesKiwoom('${s.code}','${s.name||s.code}',60)" title="키움 ka10080 · 60분봉 (연속조회)">🗂60분</button>
|
||
<button class="btn btn-sm btn-outline-secondary" style="color:#58a6ff;border-color:#58a6ff" onclick="hdFetchMinCandlesKiwoom('${s.code}','${s.name||s.code}',15)" title="키움 ka10080 · 15분봉">15분</button>
|
||
<button class="btn btn-sm btn-outline-secondary" style="color:#58a6ff;border-color:#58a6ff" onclick="hdFetchMinCandlesKiwoom('${s.code}','${s.name||s.code}',5)" title="키움 ka10080 · 5분봉">5분</button>
|
||
<button class="btn btn-sm btn-outline-secondary" style="color:#58a6ff;border-color:#58a6ff" onclick="hdFetchMinCandlesKiwoom('${s.code}','${s.name||s.code}',3)" title="키움 ka10080 · 3분봉">3분</button>
|
||
<button class="btn btn-sm btn-primary" onclick="hdRunBacktest('${s.code}','${s.name||s.code}')" title="추세추종 전략 (MA + RSI)">🚀 추세BT</button>
|
||
<button class="btn btn-sm btn-info" style="color:#fff" onclick="hdRunV1Backtest('${s.code}','${s.name||s.code}')" title="RSI 3단계 분할매수 전략 (횡보장)">📊 분할매수BT</button>
|
||
<button class="btn btn-sm btn-warning" onclick="hdParamSearch('${s.code}','${s.name||s.code}')" title="추세추종 파라미터 탐색">🔍 추세탐색</button>
|
||
<button class="btn btn-sm btn-outline-info" onclick="hdV1ParamSearch('${s.code}','${s.name||s.code}')" title="분할매수 파라미터 탐색">🔍 분할탐색</button>
|
||
<button class="btn btn-sm btn-success" onclick="hdSaveConfig('${s.code}','${s.name||s.code}')">💾 저장</button>
|
||
</div>
|
||
</div>
|
||
</div>`);
|
||
});
|
||
}
|
||
|
||
function hdGetCfg(code) {
|
||
const fields = ['rsi_period','rsi_buy1','rsi_buy2','rsi_buy3','rsi_sell',
|
||
'take_profit_pct','stop_loss_pct','slot_money',
|
||
'buy1_ratio','buy2_ratio','buy3_ratio',
|
||
'atr_period','atr_mult',
|
||
'ma_fast','ma_slow','trail_stop_pct','trend_filter',
|
||
'ath_drop_min_pct','year_drop_min_pct','w52_drop_min_pct'];
|
||
const cfg = {code};
|
||
fields.forEach(f => {
|
||
const el = $(`cfg_${code}_${f}`);
|
||
if (el) cfg[f] = parseFloat(el.value);
|
||
});
|
||
return cfg;
|
||
}
|
||
|
||
function hdFetchCandles(code, name) {
|
||
const start = $('hd_fetch_start').value;
|
||
const end = $('hd_end').value;
|
||
if (!start || !end) { alert('캔들 수집 날짜를 설정하세요'); return; }
|
||
showSpinner(true);
|
||
fetch('/api/holding/candles/fetch', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({code, start, end}),
|
||
})
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
showSpinner(false);
|
||
if (d.error) { alert('❌ ' + d.error); return; }
|
||
alert(`✅ ${name}(${code}) 일봉 수집 완료\n수집: ${d.fetched}봉 / 저장: ${d.saved}봉`);
|
||
hdLoadStocks();
|
||
})
|
||
.catch(err => { showSpinner(false); alert('오류: ' + err); });
|
||
}
|
||
|
||
function hdFetchMinCandlesKiwoom(code, name, tf) {
|
||
tf = parseInt(String(tf != null ? tf : '60'), 10) || 60;
|
||
const start = $('hd_fetch_start').value;
|
||
const end = $('hd_end').value;
|
||
if (!start || !end) { alert('캔들 수집 날짜를 설정하세요'); return; }
|
||
if (!confirm(`🗂 ${name}(${code}) 키움 ${tf}분봉 수집 시작\n기간: ${start} ~ ${end}\n\n키움 API는 1회 약 900봉, 연속조회로 긴 구간 수집 가능.\nDB에 KIWOOM_APP_KEY / KIWOOM_APP_SECRET 설정 필요.\n계속하시겠습니까?`)) return;
|
||
|
||
fetch('/api/holding/min_candles/fetch_kiwoom', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({code, start, end, tf, market_type: 'KR', exchange: 'KRX', symbol: code}),
|
||
})
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
if (d.error) { alert('❌ ' + d.error); return; }
|
||
const jobId = d.job_id;
|
||
const statusEl = document.getElementById(`min_status_${code}`) || (() => {
|
||
const el = document.createElement('span');
|
||
el.id = `min_status_${code}`;
|
||
el.style.cssText = 'font-size:11px;color:var(--accent);margin-left:6px';
|
||
return el;
|
||
})();
|
||
function poll() {
|
||
fetch(`/api/holding/min_candles/status/${jobId}`)
|
||
.then(r => r.json())
|
||
.then(j => {
|
||
if (j.status === 'running') {
|
||
const jtf = j.tf != null ? j.tf : tf;
|
||
statusEl.textContent = `⏳ 키움${jtf}분 수집중… ${j.fetched}봉 → 저장 ${j.saved}봉`;
|
||
setTimeout(poll, 2000);
|
||
} else if (j.status === 'done') {
|
||
const jtf = j.tf != null ? j.tf : tf;
|
||
statusEl.textContent = `✅ 완료! ${j.saved}봉 저장 (${jtf}분·키움)`;
|
||
hdLoadStocks();
|
||
} else {
|
||
statusEl.textContent = `❌ ${j.error}`;
|
||
}
|
||
})
|
||
.catch(() => setTimeout(poll, 3000));
|
||
}
|
||
poll();
|
||
})
|
||
.catch(err => alert('오류: ' + err));
|
||
}
|
||
|
||
function hdFetchMinCandles(code, name) {
|
||
// 60분봉 수집(KIS) — 백그라운드 스레드로 실행, 폴링으로 진행상황 표시
|
||
const start = $('hd_fetch_start').value;
|
||
const end = $('hd_end').value;
|
||
if (!start || !end) { alert('캔들 수집 날짜를 설정하세요'); return; }
|
||
if (!confirm(`⏱ ${name}(${code}) KIS 60분봉 수집 시작\n기간: ${start} ~ ${end}\n\n⚠️ KIS는 최근 2일치 한계. 긴 기간은 [키움 60분봉]을 사용하세요.\n계속하시겠습니까?`)) return;
|
||
|
||
fetch('/api/holding/min_candles/fetch', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({code, start, end, tf: 60}),
|
||
})
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
if (d.error) { alert('❌ ' + d.error); return; }
|
||
const jobId = d.job_id;
|
||
// 수집 상태 표시 영역 업데이트 (스피너 대신 인라인 표시)
|
||
const btn = document.querySelector(`[onclick*="hdFetchMinCandles('${code}'"]`);
|
||
const statusEl = document.getElementById(`min_status_${code}`) || (() => {
|
||
const el = document.createElement('span');
|
||
el.id = `min_status_${code}`;
|
||
el.style.cssText = 'font-size:11px;color:var(--accent);margin-left:6px';
|
||
if (btn) btn.parentNode.insertBefore(el, btn.nextSibling);
|
||
return el;
|
||
})();
|
||
|
||
function poll() {
|
||
fetch(`/api/holding/min_candles/status/${jobId}`)
|
||
.then(r => r.json())
|
||
.then(j => {
|
||
if (j.error) { statusEl.textContent = '❌ ' + j.error; return; }
|
||
const d_str = j.current_date ? ` (${j.current_date.slice(0,4)}-${j.current_date.slice(4,6)}-${j.current_date.slice(6,8)})` : '';
|
||
if (j.status === 'running') {
|
||
statusEl.textContent = `⏳ 수집중… 1분봉 ${j.fetched}개 → 저장 ${j.saved}봉${d_str}`;
|
||
setTimeout(poll, 2000);
|
||
} else if (j.status === 'done') {
|
||
statusEl.textContent = `✅ 완료! 저장 ${j.saved}봉`;
|
||
hdLoadStocks();
|
||
} else {
|
||
statusEl.textContent = `❌ 오류: ${j.error}`;
|
||
}
|
||
})
|
||
.catch(() => setTimeout(poll, 3000));
|
||
}
|
||
poll();
|
||
})
|
||
.catch(err => alert('오류: ' + err));
|
||
}
|
||
|
||
function hdRunMinBacktest(code, name) {
|
||
// 60분봉 기반 백테스트 — 현재 카드 파라미터 적용
|
||
const cfg = hdGetCfg(code);
|
||
const start = $('hd_start').value;
|
||
const end = $('hd_end').value;
|
||
const qs = new URLSearchParams();
|
||
qs.set('code', code);
|
||
qs.set('start', start);
|
||
qs.set('end', end);
|
||
qs.set('tf', '60');
|
||
Object.entries(cfg).forEach(([k, v]) => {
|
||
if (k === 'code') return;
|
||
if (v === undefined || v === null || Number.isNaN(v)) return;
|
||
qs.set(k, String(v));
|
||
});
|
||
showSpinner(true);
|
||
fetch('/api/holding/min_backtest?' + qs.toString())
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
showSpinner(false);
|
||
if (d.error) { alert('❌ ' + d.error); return; }
|
||
hdCurrentCode = code;
|
||
hdCurrentName = name;
|
||
hdCurrentCfg = cfg;
|
||
// 결과 렌더링 — 기존 일봉 백테스트와 동일 함수 재사용, 제목만 구분
|
||
hdRenderResult(d, `[${name}] 60분봉 백테스트 결과 (${d.candle_count}봉)`);
|
||
})
|
||
.catch(err => { showSpinner(false); alert('오류: ' + err); });
|
||
}
|
||
|
||
function hdRunBacktest(code, name) {
|
||
const cfg = hdGetCfg(code);
|
||
const start = $('hd_start').value;
|
||
const end = $('hd_end').value;
|
||
const qs = new URLSearchParams();
|
||
qs.set('code', code);
|
||
qs.set('start', start);
|
||
qs.set('end', end);
|
||
Object.entries(cfg).forEach(([k, v]) => {
|
||
if (k === 'code') return;
|
||
if (v === undefined || v === null || Number.isNaN(v)) return;
|
||
qs.set(k, String(v));
|
||
});
|
||
showSpinner(true);
|
||
fetch('/api/holding/backtest?' + qs.toString())
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
showSpinner(false);
|
||
if (d.error) { alert('❌ ' + d.error); return; }
|
||
hdCurrentCode = code;
|
||
hdCurrentName = name;
|
||
hdRenderResult(d, name);
|
||
})
|
||
.catch(err => { showSpinner(false); alert('오류: ' + err); });
|
||
}
|
||
|
||
function hdGetV1Cfg(code) {
|
||
// V1 전략 파라미터: 카드에서 RSI/낙폭 관련 값만 수집 (MA/추세 파라미터 제외)
|
||
const fields = ['rsi_period','rsi_buy1','rsi_buy2','rsi_buy3','rsi_sell',
|
||
'take_profit_pct','stop_loss_pct','slot_money',
|
||
'buy1_ratio','buy2_ratio','buy3_ratio',
|
||
'ath_drop_min_pct','year_drop_min_pct','w52_drop_min_pct'];
|
||
const cfg = {code};
|
||
fields.forEach(f => {
|
||
const el = $(`cfg_${code}_${f}`);
|
||
if (el) cfg[f] = parseFloat(el.value);
|
||
});
|
||
return cfg;
|
||
}
|
||
|
||
function hdRunV1Backtest(code, name) {
|
||
// V1 RSI 분할매수 백테스트
|
||
const cfg = hdGetV1Cfg(code);
|
||
const start = $('hd_start').value;
|
||
const end = $('hd_end').value;
|
||
const qs = new URLSearchParams();
|
||
qs.set('code', code);
|
||
qs.set('start', start);
|
||
qs.set('end', end);
|
||
Object.entries(cfg).forEach(([k, v]) => {
|
||
if (k === 'code') return;
|
||
if (v === undefined || v === null || Number.isNaN(v)) return;
|
||
qs.set(k, String(v));
|
||
});
|
||
showSpinner(true);
|
||
fetch('/api/holding/v1/backtest?' + qs.toString())
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
showSpinner(false);
|
||
if (d.error) { alert('❌ ' + d.error); return; }
|
||
hdRenderResult(d, name + ' [분할매수V1]');
|
||
})
|
||
.catch(err => { showSpinner(false); alert('오류: ' + err); });
|
||
}
|
||
|
||
function hdMinTradesQs() {
|
||
const el = $('hd_min_trades');
|
||
if (!el) return 1;
|
||
let v = parseInt(el.value, 10);
|
||
if (isNaN(v)) return 1;
|
||
return Math.max(0, Math.min(30, v));
|
||
}
|
||
|
||
function hdV1ParamSearch(code, name) {
|
||
// V1 RSI 분할매수 파라미터탐색
|
||
const cfg = hdGetV1Cfg(code);
|
||
const start = $('hd_start').value;
|
||
const end = $('hd_end').value;
|
||
const minTr = hdMinTradesQs();
|
||
const qs = new URLSearchParams();
|
||
qs.set('code', code);
|
||
qs.set('start', start);
|
||
qs.set('end', end);
|
||
qs.set('min_trades', String(minTr));
|
||
Object.entries(cfg).forEach(([k, v]) => {
|
||
if (k === 'code') return;
|
||
if (v === undefined || v === null || Number.isNaN(v)) return;
|
||
qs.set(k, String(v));
|
||
});
|
||
showSpinner(true);
|
||
fetch('/api/holding/v1/param_search?' + qs.toString())
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
showSpinner(false);
|
||
if (d.error) { alert('❌ ' + d.error); return; }
|
||
hdRenderParamSearch(d.top, code, name + ' [분할매수V1]', d.meta);
|
||
})
|
||
.catch(err => { showSpinner(false); alert('오류: ' + err); });
|
||
}
|
||
|
||
function hdParamSearch(code, name) {
|
||
const start = $('hd_start').value;
|
||
const end = $('hd_end').value;
|
||
// 카드의 현재 파라미터를 기준값으로 전달 (rsi_sell, rsi_period 등 그리드에 없는 값도 정확히 반영)
|
||
const cfg = hdGetCfg(code);
|
||
const minTr = hdMinTradesQs();
|
||
const qs = new URLSearchParams();
|
||
qs.set('code', code);
|
||
qs.set('start', start);
|
||
qs.set('end', end);
|
||
qs.set('min_trades', String(minTr));
|
||
Object.entries(cfg).forEach(([k, v]) => {
|
||
if (k === 'code') return;
|
||
if (v === undefined || v === null || Number.isNaN(v)) return;
|
||
qs.set(k, String(v));
|
||
});
|
||
showSpinner(true);
|
||
fetch('/api/holding/param_search?' + qs.toString())
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
showSpinner(false);
|
||
if (d.error) { alert('❌ ' + d.error); return; }
|
||
hdRenderParamSearch(d.top, code, name, d.meta);
|
||
})
|
||
.catch(err => { showSpinner(false); alert('오류: ' + err); });
|
||
}
|
||
|
||
function hdSaveConfig(code, name) {
|
||
const cfg = hdGetCfg(code);
|
||
cfg.name = name;
|
||
fetch(`/api/holding/config/${code}`, {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify(cfg),
|
||
})
|
||
.then(async r => {
|
||
const ct = r.headers.get('content-type') || '';
|
||
const raw = await r.text();
|
||
if (!ct.includes('application/json')) {
|
||
throw new Error(`HTTP ${r.status} — JSON이 아닌 응답(로그인/404/서버오류 페이지일 수 있음): ${raw.slice(0, 200)}`);
|
||
}
|
||
let d;
|
||
try { d = JSON.parse(raw); } catch (e) { throw new Error('JSON 파싱 실패: ' + raw.slice(0, 120)); }
|
||
if (!r.ok) throw new Error(d.error || d.message || `HTTP ${r.status}`);
|
||
return d;
|
||
})
|
||
.then(d => {
|
||
if (d.ok) alert(`✅ ${name}(${code}) 파라미터 저장 완료`);
|
||
else alert('❌ ' + (d.error || '저장 실패'));
|
||
})
|
||
.catch(err => alert('오류: ' + err));
|
||
}
|
||
|
||
function hdApplySearchResult(code, name, row) {
|
||
// apply_cfg: 탐색에 사용된 전체 cfg / params: 그리드만(구버전) / 그 외: row 자체가 cfg dict 인 경우
|
||
let src;
|
||
if (row && row.apply_cfg && typeof row.apply_cfg === 'object') {
|
||
src = row.apply_cfg;
|
||
} else if (row && row.params && typeof row.params === 'object') {
|
||
src = row.params;
|
||
} else {
|
||
src = row;
|
||
}
|
||
Object.entries(src).forEach(([k, v]) => {
|
||
if (v === undefined || v === null) return;
|
||
const el = $(`cfg_${code}_${k}`);
|
||
if (!el) return;
|
||
el.value = String(v);
|
||
});
|
||
alert(`✅ ${name} 탐색 결과를 카드에 반영했습니다.\n💾 설정저장 버튼을 눌러 DB에 저장하세요.`);
|
||
}
|
||
|
||
function hdRenderResult(d, name) {
|
||
const s = d.summary || {};
|
||
$('hd_result_area').style.display = '';
|
||
$('hd_search_result').style.display = 'none';
|
||
const hHint = $('hd_search_hint');
|
||
if (hHint) { hHint.style.display = 'none'; hHint.textContent = ''; }
|
||
const nBar = d.candle_count != null ? d.candle_count : ((d.candle_rows && d.candle_rows.length) || 0);
|
||
$('hd_result_title').textContent = `📈 [${name}] 백테스트 결과 (${nBar}봉)`;
|
||
|
||
const pe = $('hd_bt_params_echo');
|
||
if (pe) {
|
||
if (d.params) {
|
||
const p = d.params;
|
||
const tfOn = Number(p.trend_filter) >= 0.5;
|
||
pe.style.display = '';
|
||
pe.innerHTML =
|
||
'이번 실행에 쓰인 파라미터(서버 반영): '
|
||
+ `MA ${p.ma_fast}/${p.ma_slow} · 매수RSI≤ ${p.rsi_buy1}/${p.rsi_buy2} · RSI기간 ${p.rsi_period} · 비중 ${p.buy1_ratio}/${p.buy2_ratio}/${p.buy3_ratio}%`
|
||
+ ` · 투자금 ${fmt(p.slot_money)}원 · 추세필터 ${tfOn ? 'ON' : 'OFF'} · 샹들리에×${p.atr_mult}<br>`
|
||
+ '<span style="opacity:0.88">요약의 봇 수익률·거래 건수가 안 바뀌면, 바꾼 값이 그 구간에선 <b>진입/청산 조건을 바꾸지 못한 것</b>입니다(전일비 표와는 별개).</span>';
|
||
} else {
|
||
pe.style.display = 'none';
|
||
pe.textContent = '';
|
||
}
|
||
}
|
||
|
||
$('hd_total').textContent = (s.total_trades||0) + '건';
|
||
$('hd_wr').textContent = (s.win_rate||0) + '%';
|
||
colorPnl($('hd_wr'), (s.win_rate||0) - 50);
|
||
$('hd_pnl').textContent = fmtKrw(s.total_pnl);
|
||
colorPnl($('hd_pnl'), s.total_pnl);
|
||
$('hd_pf').textContent = (s.profit_factor||0) >= 999 ? '∞' : (s.profit_factor||0);
|
||
colorPnl($('hd_pf'), (s.profit_factor||0) - 1);
|
||
$('hd_mdd').textContent = '-' + fmtWon(s.max_drawdown) + '원';
|
||
$('hd_hold').textContent = (s.avg_hold_days||0) + '일';
|
||
|
||
const sign = v => (v > 0 ? '+' : '');
|
||
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', `<tr>
|
||
<td>${r.dt || r.date}</td>
|
||
<td>${fmt(r.open)}</td><td>${fmt(r.high)}</td><td>${fmt(r.low)}</td><td>${fmt(r.close)}</td>
|
||
<td style="color:${colW};font-weight:600">${wStr}</td>
|
||
<td style="color:${colP};font-weight:600">${pStr}</td>
|
||
</tr>`);
|
||
});
|
||
} else {
|
||
cBlk.style.display = 'none';
|
||
cTb.innerHTML = '';
|
||
}
|
||
}
|
||
|
||
lineChart('hd_equity_chart', d.equity.map(e=>e.date), d.equity.map(e=>e.cum_pnl),
|
||
'누적손익', '#bc8cff');
|
||
const rKeys = Object.keys(d.reasons||{});
|
||
doughnutChart('hd_reason_chart', rKeys, rKeys.map(k=>d.reasons[k]));
|
||
|
||
renderVirtualTrades('hd_trade_tbody', d.trades || [], {
|
||
meta: { code: hdCurrentCode || '', name: hdCurrentName || '' },
|
||
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 = '<tr>' +
|
||
keys.map(k => `<th>${labelMap[k] || k}</th>`).join('') +
|
||
'<th>손익(원)</th><th>승률</th><th>거래</th><th>PF</th><th>보유(일)</th><th>적용</th></tr>';
|
||
|
||
const tbody = $('hd_search_tbody');
|
||
tbody.innerHTML = '';
|
||
(top||[]).forEach((r, idx) => {
|
||
const p = r.params;
|
||
const pnlCls = r.total_pnl > 0 ? 'text-pnl-pos' : 'text-pnl-neg';
|
||
const paramCells = keys.map(k => {
|
||
const v = p[k];
|
||
if (v === undefined || v === null) return '<td>-</td>';
|
||
// % 단위 컬럼은 % 표시
|
||
const isPct = k.includes('pct') || k.includes('rate');
|
||
return `<td>${isPct ? v+'%' : v}</td>`;
|
||
}).join('');
|
||
const rowJson = JSON.stringify(r).replace(/</g, '\\u003c');
|
||
tbody.insertAdjacentHTML('beforeend', `
|
||
<tr>
|
||
${paramCells}
|
||
<td class="${pnlCls}">${fmtWon(r.total_pnl)}</td>
|
||
<td>${r.win_rate}%</td><td>${r.total_trades}</td>
|
||
<td>${r.pf}</td><td>${r.avg_hold}일</td>
|
||
<td><button class="btn btn-xs btn-outline-success" style="font-size:11px;padding:1px 6px"
|
||
onclick='hdApplySearchResult("${code}","${name}",${rowJson})'>적용</button></td>
|
||
</tr>`);
|
||
});
|
||
const hint = $('hd_search_hint');
|
||
if (hint) {
|
||
hint.style.display = '';
|
||
let metaHtml = '';
|
||
if (meta && meta.cartesian_product != null) {
|
||
const fk = (meta.fixed_param_keys || []).map(fixedLabel).join(', ');
|
||
const gk = (meta.grid_keys || []).map(fixedLabel).join(', ');
|
||
const mtNote = meta.min_trades === 0 ? '(필터 없음)' : `≥${meta.min_trades}`;
|
||
metaHtml =
|
||
`<br><span style="color:var(--accent)">탐색 동작 요약:</span> 전체 그리드 조합 <b>${meta.cartesian_product}</b>개 중 `
|
||
+ `백테스트 실행 <b>${meta.backtests_run}</b>회 → TOP 후보 <b>${meta.passed}</b>개만 표시(최소거래 ${mtNote}). `
|
||
+ `제외: MA무효 <b>${meta.skipped_invalid_ma}</b>, RSI순서무효 <b>${meta.skipped_invalid_rsi_order}</b>, `
|
||
+ `백테오류 <b>${meta.skipped_backtest_error}</b>, 거래수부족 <b>${meta.skipped_below_min_trades}</b>.<br>`
|
||
+ `<span style="color:var(--muted)">그리드에서 바꾸는 항목:</span> ${gk}. `
|
||
+ `<span style="color:var(--muted)">카드값으로 고정(탐색 안 함):</span> ${fk || '—'}.<br>`
|
||
+ `<b>실매매 봇</b>은 DB에 저장된 종목 설정만 사용합니다. TOP을 쓰려면 <b>[적용] → [저장]</b> 하세요.<br>`
|
||
+ `<span style="color:var(--muted)">탐색 base</span>는 <b>DB 최근 저장</b> 전체에 카드 입력을 덮어씁니다(비중1·2·3·투자금이 추세BT와 같게 맞춰짐).`;
|
||
}
|
||
hint.innerHTML =
|
||
'표의 숫자는 각 행마다 <b>다른 파라미터 조합</b>으로 돌린 백테스트 결과입니다(고정이 아님). '
|
||
+ 'ATH낙폭% 등은 그리드에 포함된 경우에만 바뀝니다.<br>'
|
||
+ 'Buy & Hold 비교는 백테스트 화면의 추세BT 결과를 참고하세요.'
|
||
+ metaHtml;
|
||
}
|
||
if ((!top || !top.length) && meta && $('hd_search_hint')) {
|
||
$('hd_search_hint').innerHTML += '<br><span style="color:#f85149">TOP이 비었습니다. 위 「탐색 최소거래」를 0 또는 1로 낮추거나, 백테스트 기간을 넓혀 보세요.</span>';
|
||
}
|
||
$('hd_result_area').scrollIntoView({behavior:'smooth'});
|
||
}
|
||
|
||
// ────────────────────────────────────────────
|
||
// 운영 설정 탭 (live_config)
|
||
// ────────────────────────────────────────────
|
||
let _lcTimer = null;
|
||
let _lcData = null;
|
||
|
||
function lcInitDate() {
|
||
const el = $('lc_date');
|
||
if (!el) return;
|
||
if (!el.value) {
|
||
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(/</g, '<').replace(/"/g, '"');
|
||
}
|
||
|
||
function lcRenderStatus(status) {
|
||
const box = $('lc_status_cards');
|
||
if (!box || !status) return;
|
||
const g = status.global || {};
|
||
const haltedCls = g.buy_halted ? 'badge-loss' : 'badge-win';
|
||
const haltedTxt = g.buy_halted ? '매수중단' : '매수허용';
|
||
let html = `
|
||
<div class="col-md-4">
|
||
<div class="card p-3 stat-card">
|
||
<div class="stat-label">총합 봇 실현</div>
|
||
<div class="stat-value ${dashPnlCls(g.realized_pnl_krw)}">${dashFmtKrw(g.realized_pnl_krw)}</div>
|
||
<div style="font-size:12px;color:var(--muted)">
|
||
목표 ${dashFmtKrw(g.target_krw)} / ${Number(g.target_pct || 0).toFixed(2)}%
|
||
· 한도 ${dashFmtKrw(g.budget_krw)}
|
||
· <span class="${haltedCls}">${haltedTxt}</span>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
(status.strategies || []).forEach(row => {
|
||
if (!row.enabled) return;
|
||
const subHalt = row.buy_halted ? 'badge-loss">중단' : 'badge-win">허용';
|
||
html += `
|
||
<div class="col-md-4 col-lg-3">
|
||
<div class="card p-3" style="font-size:13px">
|
||
<b>${lcEsc(row.label)}</b> <code style="font-size:11px">${lcEsc(row.strategy_id)}</code>
|
||
<div class="${dashPnlCls(row.realized_pnl_krw)}">${dashFmtKrw(row.realized_pnl_krw)}</div>
|
||
<div style="color:var(--muted);font-size:11px">
|
||
${Number(row.return_pct || 0).toFixed(2)}% · <span class="${subHalt}</span>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
});
|
||
box.innerHTML = html;
|
||
}
|
||
|
||
function lcFieldInput(f) {
|
||
const id = 'lc_' + f.key;
|
||
const hint = f.hint ? `<div class="lc-field-hint">${lcEsc(f.hint)}</div>` : '';
|
||
const tbl = f.table ? `<code class="lc-field-tbl" title="저장 테이블">${lcEsc(f.table)}</code>` : '';
|
||
const keyTag = f.key
|
||
? `<code class="lc-field-key" title="${lcEsc(f.key)}">${lcEsc(f.key)}</code>`
|
||
: '';
|
||
if (f.type === 'bool') {
|
||
const chk = f.value ? 'checked' : '';
|
||
return `<div class="lc-field" title="${lcEsc(f.key || '')}">
|
||
<label class="lc-field-label"><input type="checkbox" data-lc-key="${lcEsc(f.key)}" data-lc-type="bool" id="${id}" ${chk}>
|
||
${lcEsc(f.label)} ${keyTag}</label> ${tbl}${hint}
|
||
</div>`;
|
||
}
|
||
if (f.type === 'text') {
|
||
const val = f.value != null ? f.value : '';
|
||
return `<div class="lc-field" title="${lcEsc(f.key || '')}">
|
||
<label class="lc-field-label" for="${id}">${lcEsc(f.label)} ${keyTag}</label> ${tbl}
|
||
<input type="text" class="form-control form-control-sm" data-lc-key="${lcEsc(f.key)}" data-lc-type="text"
|
||
id="${id}" value="${lcEsc(val)}" placeholder="예: 005930,000660">
|
||
${hint}
|
||
</div>`;
|
||
}
|
||
const step = f.type === 'int' ? '1' : '0.01';
|
||
const val = f.value != null ? f.value : '';
|
||
return `<div class="lc-field" title="${lcEsc(f.key || '')}">
|
||
<label class="lc-field-label" for="${id}">${lcEsc(f.label)} ${keyTag}</label> ${tbl}
|
||
<input type="number" class="form-control form-control-sm" data-lc-key="${lcEsc(f.key)}" data-lc-type="${lcEsc(f.type)}"
|
||
id="${id}" value="${lcEsc(val)}" step="${step}">
|
||
${hint}
|
||
</div>`;
|
||
}
|
||
|
||
/** 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 = '<div class="lc-field-grid">';
|
||
(fields || []).forEach(f => { html += lcFieldInput(f); });
|
||
html += '</div>';
|
||
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')
|
||
? ''
|
||
: `<span class="lc-strat-sid">${lcEsc(p.sid)}</span>`;
|
||
return `<div class="${cls}">
|
||
<div class="lc-strat-head"><span>${lcEsc(p.label)}</span>${sidTag}</div>
|
||
${lcRenderFieldGrid(p.fields)}
|
||
</div>`;
|
||
}
|
||
|
||
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 += `<div class="card p-3 mb-3 lc-group" data-lc-group="${lcEsc(g.id)}">
|
||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||
<div>
|
||
<div class="card-title mb-0">${lcEsc(g.title)}</div>
|
||
${g.hint ? `<div style="font-size:12px;color:var(--muted)">${lcEsc(g.hint)}</div>` : ''}
|
||
</div>
|
||
<button type="button" class="btn btn-sm btn-outline-primary" onclick="lcSaveGroup('${lcEsc(g.id)}')">💾 이 섹션 저장</button>
|
||
</div>`;
|
||
if (useBlocks) {
|
||
html += '<div class="lc-strat-stack">';
|
||
renderParts.forEach(p => { html += lcRenderBlock(p); });
|
||
html += '</div>';
|
||
} else {
|
||
html += lcRenderFieldGrid((renderParts[0] && renderParts[0].fields) || g.fields || []);
|
||
}
|
||
html += '</div>';
|
||
});
|
||
root.innerHTML = html;
|
||
}
|
||
|
||
function lcCollectPatch(scopeGroupId) {
|
||
const patch = {};
|
||
const root = scopeGroupId
|
||
? document.querySelector(`.lc-group[data-lc-group="${scopeGroupId}"]`)
|
||
: $('tab-liveconfig');
|
||
if (!root) return patch;
|
||
root.querySelectorAll('[data-lc-key]').forEach(el => {
|
||
const key = el.dataset.lcKey;
|
||
const typ = el.dataset.lcType || 'text';
|
||
if (typ === 'bool') {
|
||
patch[key] = el.checked;
|
||
} else {
|
||
patch[key] = el.value;
|
||
}
|
||
});
|
||
return patch;
|
||
}
|
||
|
||
async function lcLoad(silent) {
|
||
lcInitDate();
|
||
const day = $('lc_date').value;
|
||
if (!silent) showSpinner(true);
|
||
try {
|
||
const r = await fetch('/api/live_config?date=' + encodeURIComponent(day));
|
||
const d = await r.json();
|
||
if (!d.ok) {
|
||
if (!silent) alert(d.error || '운영 설정 조회 실패');
|
||
return;
|
||
}
|
||
_lcData = d;
|
||
$('lc_meta').textContent = '기준일 ' + d.date + ' · 갱신 ' + (d.as_of || '');
|
||
lcRenderStatus(d.status);
|
||
// 자동 갱신: 손익·익절 상태만 — 폼 입력값은 유지
|
||
if (!silent || !$('lc_groups') || !$('lc_groups').children.length) {
|
||
lcRenderGroups(d.groups);
|
||
}
|
||
} catch (e) {
|
||
if (!silent) alert('운영 설정 조회 오류: ' + e);
|
||
} finally {
|
||
if (!silent) showSpinner(false);
|
||
}
|
||
}
|
||
|
||
async function lcSavePatch(patch, label) {
|
||
if (!patch || !Object.keys(patch).length) {
|
||
alert('저장할 값이 없습니다.');
|
||
return;
|
||
}
|
||
if (!confirm('💾 ' + (label || '운영 설정') + '을(를) DB에 저장할까요?\n봇은 재시작 없이 다음 루프부터 반영됩니다.')) return;
|
||
showSpinner(true);
|
||
const resEl = $('lc_save_result');
|
||
try {
|
||
const r = await fetch('/api/live_config/save', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ patch }),
|
||
});
|
||
const d = await r.json();
|
||
if (!d.ok) {
|
||
alert(d.error || '저장 실패');
|
||
return;
|
||
}
|
||
const tbl = d.saved_by_table
|
||
? Object.entries(d.saved_by_table).map(([t, ks]) => t + ': ' + ks.length + '키').join(' · ')
|
||
: '';
|
||
resEl.innerHTML = `<span style="color:#3fb950">✅ 저장 완료 (env_id=${d.env_id}) — ${tbl}</span>`;
|
||
await lcLoad(false);
|
||
} catch (e) {
|
||
alert('저장 오류: ' + e);
|
||
} finally {
|
||
showSpinner(false);
|
||
}
|
||
}
|
||
|
||
function lcSaveGroup(groupId) {
|
||
lcSavePatch(lcCollectPatch(groupId), '섹션 ' + groupId);
|
||
}
|
||
|
||
function lcSaveAll() {
|
||
lcSavePatch(lcCollectPatch(null), '전체 운영 설정');
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════
|
||
// 박스엔진 UPDOWN (kis_trader/engine/updown_box)
|
||
// watchlist(sticky) 관리 + 박스 백테 + 글로벌 파라서치
|
||
// ════════════════════════════════════════════════════════════════
|
||
// 입력란 id ↔ 박스 cfg 키 매핑 (env UPDOWN_BOX_* 와 1:1)
|
||
const UBX_PARAM_FIELDS = [
|
||
'lookback_bars', 'range_max_pct', 'bb_bw_max', 'ma_slope_tol_pct',
|
||
'near_low_pct', 'tp_pct', 'stop_loss_pct', 'breach_buffer_pct',
|
||
'panic_from_high_pct', 'shoulder_min_high_pct', 'shoulder_cut_pct', 'max_hold_bars',
|
||
'breakout_follow',
|
||
];
|
||
|
||
function ubxOnTabShow() {
|
||
// 날짜 기본값 — 주말/휴장이면 이전 장운영일
|
||
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 `<option value="${it.code}">${it.name || it.code}${st}${pin}</option>`;
|
||
}).join('');
|
||
})
|
||
.catch(e => console.warn('[ubx stock_list]', e));
|
||
}
|
||
|
||
/** 종목 선택 시 그 종목의 박스 파라미터(있으면 핀, 없으면 글로벌) → 입력란 */
|
||
function ubxLoadStockCfg(code) {
|
||
code = String(code || '').trim();
|
||
const badge = $('ubx_stock_cfg_badge');
|
||
if (!/^\d{6}$/.test(code)) { if (badge) badge.innerHTML = ''; return; }
|
||
fetch('/api/updown_box/stock_cfg?code=' + encodeURIComponent(code))
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
if (d.error) { if (badge) badge.innerHTML = ''; return; }
|
||
const eff = d.effective || {};
|
||
UBX_PARAM_FIELDS.forEach(k => {
|
||
const el = $('ubx_' + k);
|
||
if (el && eff[k] != null) el.value = eff[k];
|
||
});
|
||
const rt = $('ubx_ratchet_tiers');
|
||
if (rt) rt.value = (eff.ratchet_tiers != null ? eff.ratchet_tiers : '');
|
||
if (typeof ubxSyncRatchetShoulderColors === 'function') ubxSyncRatchetShoulderColors();
|
||
const n = Object.keys(d.overrides || {}).length;
|
||
if (badge) {
|
||
badge.innerHTML = n > 0
|
||
? `<span style="color:#3fb950">★ 종목별 핀 ${n}개 적용중</span> <span class="text-muted">(저장값 로드)</span>`
|
||
: `<span class="text-muted">글로벌 사용중 (핀 없음)</span>`;
|
||
}
|
||
})
|
||
.catch(e => console.warn('[ubx stock_cfg]', e));
|
||
}
|
||
|
||
/** 현재 입력값을 [종목코드] 종목별 파라미터로 고정 저장(핀) */
|
||
function ubxSaveStockCfg() {
|
||
const code = ($('ubx_code').value || '').trim();
|
||
if (!/^\d{6}$/.test(code)) { alert('종목코드(6자리)를 선택/입력하세요'); return; }
|
||
const overrides = {};
|
||
UBX_PARAM_FIELDS.forEach(k => {
|
||
const el = $('ubx_' + k);
|
||
if (el && el.value !== '' && el.value != null) overrides[k] = el.value;
|
||
});
|
||
const rt = $('ubx_ratchet_tiers');
|
||
if (rt) overrides.ratchet_tiers = String(rt.value || '').trim(); // '' = 래칫 OFF
|
||
if (!confirm(code + ' 종목을 이 파라미터로 고정(핀)할까요?\n실매·백테 모두 이 종목엔 글로벌 대신 이 값이 적용됩니다.')) return;
|
||
fetch('/api/updown_box/stock_cfg', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ code, name: '', overrides }),
|
||
})
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
if (!d.ok) { alert('저장 실패: ' + (d.error || '')); return; }
|
||
alert('✅ ' + code + ' 종목별 핀 저장 (' + (d.saved_keys || []).length + '개 파라미터)');
|
||
ubxLoadStockList(); ubxLoadStockCfg(code);
|
||
})
|
||
.catch(e => alert('오류: ' + e));
|
||
}
|
||
|
||
/** 이 종목의 핀 해제 → 글로벌 복귀 */
|
||
function ubxClearStockCfg() {
|
||
const code = ($('ubx_code').value || '').trim();
|
||
if (!/^\d{6}$/.test(code)) { alert('종목코드(6자리)를 선택/입력하세요'); return; }
|
||
if (!confirm(code + ' 종목별 핀을 해제하고 글로벌로 복귀할까요?')) return;
|
||
fetch('/api/updown_box/stock_cfg', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ code, clear: '1' }),
|
||
})
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
if (!d.ok) { alert('해제 실패: ' + (d.error || '')); return; }
|
||
alert('↩ ' + code + ' 글로벌로 복귀');
|
||
ubxLoadStockList(); ubxLoadCfg(); ubxLoadStockCfg(code);
|
||
})
|
||
.catch(e => alert('오류: ' + e));
|
||
}
|
||
|
||
/** 운영설정 UPDOWN_BOX_* 현재값 → 입력란 + watchlist 통계 */
|
||
function ubxLoadCfg() {
|
||
fetch('/api/updown_box/config')
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
if (!d.ok) { console.warn('[ubx cfg]', d.error); return; }
|
||
const c = d.cfg || {};
|
||
UBX_PARAM_FIELDS.forEach(k => {
|
||
const el = $('ubx_' + k);
|
||
if (el && c[k] != null) el.value = c[k];
|
||
});
|
||
const rt = $('ubx_ratchet_tiers');
|
||
if (rt) rt.value = (c.ratchet_tiers != null ? c.ratchet_tiers : '');
|
||
if (typeof ubxSyncRatchetShoulderColors === 'function') ubxSyncRatchetShoulderColors();
|
||
if ($('ubx_tf') && d.scan_tf_min) $('ubx_tf').value = String(d.scan_tf_min);
|
||
const st = $('ubx_watch_stat');
|
||
if (st) st.textContent = `— active ${d.watch_active} / max ${d.watch_max}`;
|
||
})
|
||
.catch(e => console.warn('[ubx cfg]', e));
|
||
}
|
||
|
||
/** watchlist 목록 표 */
|
||
function ubxLoadWatch() {
|
||
const activeOnly = $('ubx_watch_active_only') && $('ubx_watch_active_only').checked;
|
||
fetch('/api/updown_box/watchlist?active=' + (activeOnly ? '1' : '0'))
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
const tb = $('ubx_watch_tbody');
|
||
if (!tb) return;
|
||
if (!d.ok) { tb.innerHTML = `<tr><td colspan="10" class="text-danger">${d.error || '오류'}</td></tr>`; return; }
|
||
const rows = d.rows || [];
|
||
if (!rows.length) { tb.innerHTML = '<tr><td colspan="10" class="text-muted">등록된 관심종목 없음 (SCAN 또는 수동추가)</td></tr>'; return; }
|
||
tb.innerHTML = rows.map(r => {
|
||
const stColor = r.status === 'active' ? '#3fb950' : '#8b949e';
|
||
const sig = r.last_signal_at ? String(r.last_signal_at).slice(0, 16) : '-';
|
||
const added = r.added_at ? String(r.added_at).slice(0, 10) : '-';
|
||
return `<tr>
|
||
<td><b>${r.code}</b></td>
|
||
<td>${r.name || ''}</td>
|
||
<td style="color:${stColor}">${r.status}</td>
|
||
<td>${r.source || ''}</td>
|
||
<td>${Number(r.box_low || 0).toLocaleString()}</td>
|
||
<td>${Number(r.box_high || 0).toLocaleString()}</td>
|
||
<td>${Number(r.box_score || 0).toFixed(1)}</td>
|
||
<td>${added}</td>
|
||
<td>${sig}</td>
|
||
<td class="text-nowrap">
|
||
<button class="btn btn-sm btn-outline-info" onclick="ubxEditWatch('${r.code}', ${JSON.stringify(r.name || '')}, ${Number(r.box_low || 0)}, ${Number(r.box_high || 0)})">수정</button>
|
||
<button class="btn btn-sm btn-outline-danger" onclick="ubxRemoveWatch('${r.code}')">삭제</button>
|
||
</td>
|
||
</tr>`;
|
||
}).join('');
|
||
})
|
||
.catch(e => { const tb = $('ubx_watch_tbody'); if (tb) tb.innerHTML = `<tr><td colspan="10" class="text-danger">${e}</td></tr>`; });
|
||
}
|
||
|
||
function ubxAddWatch() {
|
||
const code = ($('ubx_add_code').value || '').trim();
|
||
if (!/^\d{6}$/.test(code)) { alert('6자리 종목코드를 입력하세요'); return; }
|
||
const body = {
|
||
code,
|
||
name: ($('ubx_add_name').value || '').trim(),
|
||
box_low: parseFloat($('ubx_add_low').value || '0') || 0,
|
||
box_high: parseFloat($('ubx_add_high').value || '0') || 0,
|
||
};
|
||
fetch('/api/updown_box/watchlist/add', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||
})
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
if (!d.ok) { alert('추가 실패: ' + (d.error || '한도초과/보유중')); return; }
|
||
$('ubx_add_code').value = ''; $('ubx_add_name').value = '';
|
||
$('ubx_add_low').value = ''; $('ubx_add_high').value = '';
|
||
ubxLoadWatch(); ubxLoadCfg();
|
||
})
|
||
.catch(e => alert('오류: ' + e));
|
||
}
|
||
|
||
/** 수정: 행 값을 입력폼에 채워 넣음 → 고친 뒤 '추가/수정' 누르면 upsert 갱신 */
|
||
function ubxEditWatch(code, name, low, high) {
|
||
$('ubx_add_code').value = code || '';
|
||
$('ubx_add_name').value = name || '';
|
||
$('ubx_add_low').value = low || '';
|
||
$('ubx_add_high').value = high || '';
|
||
const c = $('ubx_add_code');
|
||
if (c) { c.scrollIntoView({ behavior: 'smooth', block: 'center' }); c.focus(); }
|
||
}
|
||
|
||
function ubxRemoveWatch(code) {
|
||
if (!confirm(code + ' 관심종목을 삭제할까요?')) return;
|
||
fetch('/api/updown_box/watchlist/remove', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code }),
|
||
})
|
||
.then(r => r.json())
|
||
.then(d => { if (!d.ok) alert('삭제 실패: ' + (d.error || '')); ubxLoadWatch(); ubxLoadCfg(); })
|
||
.catch(e => alert('오류: ' + e));
|
||
}
|
||
|
||
/** 입력란 → 백테/파라서치 쿼리 (빈값 제외, env 오버라이드) */
|
||
function ubxCollectParamQs(qs) {
|
||
UBX_PARAM_FIELDS.forEach(k => {
|
||
const el = $('ubx_' + k);
|
||
if (el && el.value !== '' && el.value != null) qs.append(k, String(el.value));
|
||
});
|
||
const rt = $('ubx_ratchet_tiers'); // 다단 래칫(문자열) — 숫자 필드와 별도
|
||
if (rt && rt.value != null && String(rt.value).trim() !== '') {
|
||
qs.append('ratchet_tiers', String(rt.value).trim());
|
||
}
|
||
}
|
||
|
||
// 래칫/어깨컷 입력칸 색상 토글 — TAIL(tlSyncRatchetShoulderColors)과 동일.
|
||
// 다단 래칫 값이 있으면 단일 어깨컷(어깨발동·어깨폭)은 무시됨 → 빨강 표시.
|
||
function ubxSyncRatchetShoulderColors() {
|
||
const r = $('ubx_ratchet_tiers');
|
||
if (!r) return;
|
||
const smin = $('ubx_shoulder_min_high_pct');
|
||
const scut = $('ubx_shoulder_cut_pct');
|
||
const ratchetOn = !!(r.value && String(r.value).trim());
|
||
const RED = '#f85149';
|
||
const paint = (el, on) => {
|
||
if (!el) return;
|
||
if (on) {
|
||
el.style.setProperty('border-color', RED, 'important');
|
||
el.style.setProperty('background-color', 'rgba(248,81,73,0.12)', 'important');
|
||
el.style.setProperty('box-shadow', '0 0 0 1px ' + RED, 'important');
|
||
} else {
|
||
el.style.removeProperty('border-color');
|
||
el.style.removeProperty('background-color');
|
||
el.style.removeProperty('box-shadow');
|
||
}
|
||
};
|
||
// 래칫 ON → 어깨컷 빨강(무시됨) / 래칫 OFF → 래칫 빨강(OFF, 어깨컷이 트레일)
|
||
paint(smin, ratchetOn);
|
||
paint(scut, ratchetOn);
|
||
paint(r, !ratchetOn);
|
||
}
|
||
|
||
function ubxRunBacktest() {
|
||
const code = ($('ubx_code').value || '').trim();
|
||
if (!/^\d{6}$/.test(code)) { alert('종목코드(6자리)를 입력하세요'); return; }
|
||
const qs = new URLSearchParams({
|
||
code, tf: $('ubx_tf').value || '15',
|
||
start: $('ubx_start').value || '', end: $('ubx_end').value || '',
|
||
});
|
||
ubxCollectParamQs(qs);
|
||
$('ubx_status').textContent = '백테 실행 중…';
|
||
showSpinner(true);
|
||
fetch('/api/updown_box/backtest?' + qs.toString())
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
showSpinner(false);
|
||
if (d.error) { $('ubx_status').textContent = ''; alert(d.error); return; }
|
||
$('ubx_status').textContent = '';
|
||
$('ubx_ps_card').style.display = 'none';
|
||
ubxRenderSummary(d);
|
||
ubxRenderTrades(d.trades || [], `${d.code} · ${d.tf}분 · ${d.candle_count}봉`, { code: d.code, name: d.name || '' });
|
||
})
|
||
.catch(e => { showSpinner(false); $('ubx_status').textContent = ''; alert('오류: ' + e); });
|
||
}
|
||
|
||
function ubxRenderSummary(d) {
|
||
const card = $('ubx_summary_card'), box = $('ubx_summary');
|
||
if (!card || !box) return;
|
||
const bn = d.box_now || {};
|
||
const boxBadge = bn.is_box
|
||
? `<span style="color:#3fb950">박스권 ✔</span> (범위 ${Number(bn.range_pct || 0).toFixed(1)}% · 밴드폭 ${bn.bb_bw != null ? Number(bn.bb_bw).toFixed(1) : '-'}% · 기울기 ${bn.ma_slope != null ? Number(bn.ma_slope).toFixed(2) : '-'}%)`
|
||
: `<span style="color:#f0883e">비박스</span> (${bn.reason || ''})`;
|
||
const bh = (d.buy_hold_pct != null) ? Number(d.buy_hold_pct) : null;
|
||
const alpha = (d.alpha_pct != null) ? Number(d.alpha_pct) : null;
|
||
box.innerHTML = `
|
||
<div class="row g-3" style="font-size:13px">
|
||
<div class="col-auto">거래 <b>${d.n_trades}</b></div>
|
||
<div class="col-auto">봇 총손익 <b style="color:${d.total_pnl >= 0 ? '#3fb950' : '#f85149'}">${d.total_pnl}%</b></div>
|
||
<div class="col-auto">PF <b>${d.pf}</b></div>
|
||
<div class="col-auto">승률 <b>${d.win_rate}%</b></div>
|
||
${bh != null ? `<div class="col-auto">단순보유(B&H) <b style="color:${bh >= 0 ? '#3fb950' : '#f85149'}">${bh.toFixed(2)}%</b></div>` : ''}
|
||
${alpha != null ? `<div class="col-auto">초과수익(α) <b style="color:${alpha >= 0 ? '#3fb950' : '#f85149'}">${alpha >= 0 ? '+' : ''}${alpha.toFixed(2)}%</b></div>` : ''}
|
||
</div>
|
||
${alpha != null ? `<div class="mt-1" style="font-size:12px;color:${alpha >= 0 ? '#3fb950' : '#f85149'}">${alpha >= 0 ? '✔ 봇이 단순보유를 이김' : '✘ 단순보유가 더 나음 — 이 종목엔 박스전략 부적합'}</div>` : ''}
|
||
<div class="mt-2 text-muted" style="font-size:12px">현재 박스상태(최근봉): ${boxBadge}
|
||
${bn.box_low ? ` · 하단 ${Number(bn.box_low).toLocaleString()} / 상단 ${Number(bn.box_high).toLocaleString()}` : ''}</div>`;
|
||
$('ubx_summary_sub').textContent = `${d.code} · ${d.tf}분`;
|
||
card.style.display = '';
|
||
}
|
||
|
||
function ubxRenderTrades(trades, sub, meta) {
|
||
const card = $('ubx_trades_card');
|
||
if (!card) return;
|
||
meta = meta || {};
|
||
// 박스 백테 거래(run_backtest_box) → 공통 거래목록 스키마로 변환.
|
||
// 손익(원)은 100만원 슬롯 가정으로 환산(수익률%는 실제 백테값 그대로).
|
||
const mapped = (trades || []).map(t => {
|
||
const ep = Number(t.buy || 0), xp = Number(t.sell || 0);
|
||
const qty = ep > 0 ? Math.max(1, Math.floor(1000000 / ep)) : 0;
|
||
return {
|
||
code: meta.code || '',
|
||
name: meta.name || '',
|
||
buy_price: ep,
|
||
sell_price: xp,
|
||
entry_time: t.entry_time,
|
||
exit_time: t.exit_time,
|
||
profit_rate: t.pnl_pct,
|
||
pnl: Math.round((xp - ep) * qty),
|
||
qty,
|
||
sell_reason: t.reason,
|
||
hold_min: t.bars_held,
|
||
};
|
||
});
|
||
// 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 || ''} · 정렬:<b>${meta.rank_by || 'alpha'}</b>`
|
||
+ ((meta.skipped_codes && meta.skipped_codes.length) ? ` · 데이터부족 제외: ${meta.skipped_codes.join(',')}` : '')
|
||
+ (dropTxt ? `<span style="color:#f0883e">${dropTxt}</span>` : '');
|
||
if (!top.length) {
|
||
tb.innerHTML = '<tr><td colspan="9" class="text-muted">통과한 조합 없음 (최소거래 낮추거나 기간 확대)</td></tr>';
|
||
} else {
|
||
tb.innerHTML = top.map((r, i) => {
|
||
const cfgStr = Object.entries(r.apply_cfg || {}).map(([k, v]) => {
|
||
if (k === 'ratchet_tiers') return `ratchet=${(v != null && String(v).trim()) ? v : 'OFF'}`;
|
||
return `${k}=${v}`;
|
||
}).join(' ');
|
||
const cfgJson = encodeURIComponent(JSON.stringify(r.apply_cfg || {}));
|
||
const alpha = (r.alpha_pct != null) ? Number(r.alpha_pct) : null;
|
||
const bh = (r.buy_hold_pct != null) ? Number(r.buy_hold_pct) : null;
|
||
return `<tr>
|
||
<td>${i + 1}</td>
|
||
<td style="color:${(alpha != null && alpha >= 0) ? '#3fb950' : '#f85149'}"><b>${alpha != null ? (alpha >= 0 ? '+' : '') + alpha + '%' : '-'}</b></td>
|
||
<td style="color:${r.total_pnl >= 0 ? '#3fb950' : '#f85149'}">${r.total_pnl}%</td>
|
||
<td class="text-muted">${bh != null ? bh + '%' : '-'}</td>
|
||
<td>${r.pf}</td><td>${r.win_rate}%</td><td>${r.n_trades}</td>
|
||
<td style="font-size:11px">${cfgStr}</td>
|
||
<td><button class="btn btn-sm btn-outline-success" onclick="ubxApply('${cfgJson}')">ENV적용</button></td>
|
||
</tr>`;
|
||
}).join('');
|
||
}
|
||
card.style.display = '';
|
||
}
|
||
|
||
function ubxApply(cfgJson) {
|
||
let cfg;
|
||
try { cfg = JSON.parse(decodeURIComponent(cfgJson)); } catch (e) { alert('파라미터 파싱 실패'); return; }
|
||
if (!confirm('이 파라미터를 env_config UPDOWN_BOX_* 에 적용할까요?\n' +
|
||
Object.entries(cfg).map(([k, v]) => `${k}=${v}`).join('\n'))) return;
|
||
fetch('/api/updown_box/apply', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apply_cfg: cfg }),
|
||
})
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
if (!d.ok) { alert('적용 실패: ' + (d.error || '')); return; }
|
||
alert('✅ env_config 적용 (id=' + d.env_id + ')\n' +
|
||
Object.entries(d.applied || {}).map(([k, v]) => `${k}=${v}`).join('\n'));
|
||
ubxLoadCfg();
|
||
})
|
||
.catch(e => alert('오류: ' + e));
|
||
}
|
||
|
||
// ────────────────────────────────────────────
|
||
// 스피너
|
||
// ────────────────────────────────────────────
|
||
function showSpinner(v) {
|
||
$('spinner').classList.toggle('show', v);
|
||
}
|
||
|
||
// ────────────────────────────────────────────
|
||
// 최초 로드
|
||
// ────────────────────────────────────────────
|
||
loadActual();
|