Files
kis_trader/static/js/backtest.js
Your Name 2c7ad867f4 거래 빠르게 안티에서 병신만든거 커서로
feat: Implement backtest source management and enhance candle data handling

Changes:
- Introduced a new function `_apply_backtest_source_env_from_request` to manage the environment variables for candle, tick, and order book sources based on incoming requests.
- Added a teardown function `_teardown_backtest_source_env` to ensure that environment variables do not persist between requests, enhancing the stability of the backtesting environment.
- Refactored existing code to utilize the new source management functions, improving code readability and maintainability.
- Added new utility functions in `bt_candle_source.py` for fetching and managing candle data, ensuring consistency with live trading data sources.

Impact:
- These changes improve the flexibility and reliability of the backtesting framework, allowing for better management of data sources and reducing the risk of cross-request contamination.
2026-08-13 16:03:40 +09:00

8335 lines
359 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ────────────────────────────────────────────
// 유틸
// ────────────────────────────────────────────
const $ = id => document.getElementById(id);
/** 서버/로컬 휴장일 YYYY-MM-DD 집합 — dashInitDate 등 조기 호출보다 먼저 선언(TDZ 방지) */
let _krHolidays = new Set();
const fmt = n => n == null ? '-' : Number(n).toLocaleString('ko-KR');
/** 원화 가격·평가금 — 소수점 없이 정수 (KIS 주식 호가 단위) */
const fmtWon = n => {
if (n == null || n === '' || !Number.isFinite(Number(n))) return '—';
return Math.round(Number(n)).toLocaleString('ko-KR');
};
/** 원화 금액·손익 — 소수점 없이 정수 + '원' (대시보드·요약 카드) */
const fmtKrw = n => {
const w = fmtWon(n);
return w === '—' ? w : w + '원';
};
/** 달러 금액 — $1,234.56 */
const fmtUsd = n => {
if (n == null || n === '' || !Number.isFinite(Number(n))) return '—';
const v = Number(n);
const sign = v < 0 ? '-' : '';
return sign + '$' + Math.abs(v).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
};
/** 표시용 환율 (캐시/폴백). 외부 HTTP 안 탐. */
let _displayFxRate = 0;
function setDisplayFxRate(r) {
const v = Number(r);
if (Number.isFinite(v) && v > 0) _displayFxRate = v;
}
function getDisplayFxRate(fallback) {
const f = Number(fallback);
if (Number.isFinite(f) && f > 0) return f;
if (_displayFxRate > 0) return _displayFxRate;
return 0;
}
/** 해외 탭: $USD + (원 · 환율) — fxRate 없으면 원만 생략 */
const fmtUsdWithKrw = (usd, fxRate) => {
const u = fmtUsd(usd);
if (u === '—') return u;
const r = getDisplayFxRate(fxRate);
if (!Number.isFinite(r) || r <= 0) return u;
const krw = Math.round(Number(usd) * r);
return `${u} <span style="color:var(--muted);font-size:11px">(${fmtWon(krw)}원 · ${r.toFixed(2)})</span>`;
};
/** 요약 카드용 — HTML 없이 텍스트 ($ + 원) */
const fmtUsdWithKrwText = (usd, fxRate) => {
const u = fmtUsd(usd);
if (u === '—') return u;
const r = getDisplayFxRate(fxRate);
if (!Number.isFinite(r) || r <= 0) return u;
return `${u} (${fmtWon(Math.round(Number(usd) * r))}원)`;
};
/** 수익률(%) — 거래내역·가상체결 테이블 공용 */
const fmtPct = n => {
if (n == null || n === '' || !Number.isFinite(Number(n))) return '—';
const v = Number(n);
return (v >= 0 ? '+' : '') + v.toFixed(2) + '%';
};
/** 틱 분봉커버 문구 — 구독구간(주) + 전체(참고). REST 웜업 봉 때문에 전체가 낮아 보임. */
function fmtTickCoverageLabel(tickMeta) {
const m = tickMeta || {};
const sub = m.tick_bar_coverage_pct_subscribed != null
? m.tick_bar_coverage_pct_subscribed
: m.tick_bar_coverage_pct;
const all = m.tick_bar_coverage_pct_all;
const friend = m.tick_bar_coverage_pct_friend;
const codes = (m.tick_codes_with_data != null && m.tick_codes_total != null)
? `${m.tick_codes_with_data}/${m.tick_codes_total}`
: '';
let s = `구독구간 ${sub != null ? sub : '—'}%`;
if (m.tick_bar_coverage_pct_traded != null) {
const tn = m.tick_codes_traded != null ? `${m.tick_codes_traded}` : '';
s += ` · 거래종목 ${m.tick_bar_coverage_pct_traded}%` + (tn ? `(${tn})` : '');
}
if (all != null) s += ` · 전체 ${all}%`;
if (friend != null && friend !== all) s += ` · 틱종목 ${friend}%`;
if (codes) s += ` (${codes})`;
return s;
}
/** 가상 거래 내역 — 매도/청산 시각 기준 최신순 (API가 최신순이어도 프론트에서 재정렬) */
function tradeExitSortKey(t) {
const keys = ['exit_time', 'sell_time', 'sell_date', 'buy_time', 'entry_time', 'buy_date'];
for (const k of keys) {
const v = t && t[k];
if (v != null && String(v).trim()) {
return String(v).replace(/[-: T]/g, '').slice(0, 14);
}
}
return '';
}
function tradesNewestFirst(trades) {
return [...(trades || [])].sort((a, b) => tradeExitSortKey(b).localeCompare(tradeExitSortKey(a)));
}
/** 가상 거래 내역 — 전략 공통 렌더·정렬 */
const _virtualTradesCache = {};
const _virtualTradesSort = {};
const _virtualTradesOpts = {};
function fmtTradeTime(raw) {
const s = String(raw || '').trim();
if (!s) return '';
// ISO 형식('2026-06-26 09:30:25') — 초(SS)까지 표시. 날짜만(<=10자)이면 그대로.
if (s.includes('-') && (s.includes(':') || s.length <= 10)) {
return s.length <= 10 ? s : s.slice(0, 19);
}
const d = s.replace(/[-: T]/g, '');
if (d.length >= 14) {
return d.slice(0, 4) + '-' + d.slice(4, 6) + '-' + d.slice(6, 8) + ' '
+ d.slice(8, 10) + ':' + d.slice(10, 12) + ':' + d.slice(12, 14);
}
if (d.length >= 12) {
return d.slice(0, 4) + '-' + d.slice(4, 6) + '-' + d.slice(6, 8) + ' '
+ d.slice(8, 10) + ':' + d.slice(10, 12) + ':00';
}
if (d.length >= 8) return d.slice(0, 4) + '-' + d.slice(4, 6) + '-' + d.slice(6, 8);
return s;
}
function _tradeTimeSortKey(raw) {
const s = String(raw || '').trim();
if (!s) return '';
return s.replace(/[-: T]/g, '').slice(0, 14);
}
function normalizeVirtualTrade(t, meta) {
meta = meta || {};
const isUsd = String(meta.currency || '').toUpperCase() === 'USD';
const roundPx = v => {
const n = Number(v);
if (!Number.isFinite(n)) return 0;
return isUsd ? Math.round(n * 10000) / 10000 : Math.round(n);
};
const roundMoney = v => {
const n = Number(v);
if (!Number.isFinite(n)) return 0;
return isUsd ? Math.round(n * 100) / 100 : Math.round(n);
};
const ep = roundPx(t.buy_price ?? t.avg_price ?? t.entry ?? 0);
const xp = roundPx(t.sell_price ?? t.exit_price ?? t.exit ?? 0);
const qty = Number(t.qty || 0) || (ep > 0 ? Math.max(1, Math.floor(1000000 / ep)) : 0);
const pnl = roundMoney(t.pnl ?? t.realized_pnl ?? t.unrealized_pnl ?? 0);
let rateNum = Number(t.profit_rate);
if (!Number.isFinite(rateNum) && ep > 0) rateNum = (xp - ep) / ep * 100;
if (!Number.isFinite(rateNum)) rateNum = 0;
const isOpen = !!t.is_open;
const buyRaw = t.buy_time || t.entry_time || t.buy_date || '';
const sellRaw = isOpen ? '' : (t.sell_time || t.exit_time || t.sell_date || '');
const code = t.code || meta.code || '';
const name = t.name || meta.name || '';
const fxRate = (() => {
const r = Number(t.fx_rate);
if (Number.isFinite(r) && r > 0) return r;
const ymd = String(t.fx_ymd || sellRaw || buyRaw || '').slice(0, 10);
const map = meta.fxRates || {};
const m = Number(map[ymd]);
if (Number.isFinite(m) && m > 0) return m;
return getDisplayFxRate(meta.fxRate);
})();
const pnlKrw = (() => {
const k = Number(t.pnl_krw);
if (Number.isFinite(k)) return Math.round(k);
if (Number.isFinite(fxRate) && fxRate > 0) return Math.round(pnl * fxRate);
return NaN;
})();
return {
code,
name,
buyRaw,
sellRaw,
buyKey: _tradeTimeSortKey(buyRaw),
sellKey: isOpen ? _tradeTimeSortKey(buyRaw) : _tradeTimeSortKey(sellRaw),
ep,
xp,
qty,
pnl,
pnlKrw,
fxRate,
isUsd,
isOpen,
rateNum,
hold: t.hold_min ?? t.hold_days ?? t.hold_minutes ?? 0,
reason: t.sell_reason || t.reason || '',
rsi: t.rsi_entry,
entrySource: t.entry_source_label || t.entry_source || '',
exitSource: t.exit_source_label || t.exit_source || '',
debugTick: t.debug_tick || '',
parityException: !!(t.parity_exception) || _detectParityExceptionCauses(t).length > 0,
parityCause: t.parity_cause || _detectParityExceptionCauses(t).join('+'),
cumPnl: (() => {
const v = Number(t.cum_pnl);
if (!Number.isFinite(v)) return NaN;
return isUsd ? Math.round(v * 100) / 100 : Math.round(v);
})(),
cumReturnPct: Number(t.cum_return_pct ?? NaN),
entryOb: t.entry_ob || null,
exitOb: t.exit_ob || null,
};
}
function _detectParityExceptionCauses(t) {
const causes = [];
const es = String(t.entry_source || '').toLowerCase();
const xs = String(t.exit_source || '').toLowerCase();
const reason = String(t.sell_reason || t.reason || '');
const el = String(t.entry_source_label || t.entrySource || '');
const xl = String(t.exit_source_label || t.exitSource || '');
if (['ohlc_low', 'ohlc_fallback', 'ohlc_high', 'ohlc_intrabar', 'ohlc_bar'].includes(es)
|| /진입OHLC|OHLC 폴백|OHLC high/i.test(el)) {
causes.push('진입OHLC');
}
if (['ohlc_bar', 'ohlc_fallback'].includes(xs) || /청산OHLC|^OHLC$/i.test(xl)) {
causes.push('청산OHLC');
}
if (['eod_flat', 'wallclock_eod'].includes(xs) || /틱공백EOD/i.test(xl)) {
causes.push('틱공백EOD');
}
if (/ghost_purge/i.test(reason)) causes.push('ghost_purge');
return causes;
}
function sortVirtualTradeRows(rows, mode) {
const list = [...rows];
const cmpExit = (a, b) => String(a.sellKey).localeCompare(String(b.sellKey));
const cmpBuy = (a, b) => String(a.buyKey).localeCompare(String(b.buyKey));
switch (mode) {
case 'exit_asc': return list.sort(cmpExit);
case 'buy_desc': return list.sort((a, b) => cmpBuy(b, a));
case 'buy_asc': return list.sort(cmpBuy);
case 'pnl_desc': return list.sort((a, b) => b.pnl - a.pnl);
case 'pnl_asc': return list.sort((a, b) => a.pnl - b.pnl);
case 'rate_desc': return list.sort((a, b) => b.rateNum - a.rateNum);
case 'rate_asc': return list.sort((a, b) => a.rateNum - b.rateNum);
case 'exit_desc':
default: return list.sort((a, b) => cmpExit(b, a));
}
}
/** 실매·백테 거래내역 공통 컨텍스트: 한도 · 장중 누적 최고 · 최종 누적 */
function fillTradePnLContext(elOrId, opts) {
opts = opts || {};
const el = typeof elOrId === 'string' ? $(elOrId) : elOrId;
if (!el) return;
const tb = Number(opts.totalBudget || 0);
const peak = Number(opts.peakCum || 0);
const peakAt = opts.peakAt ? fmtTradeTime(opts.peakAt) : '';
const label = opts.label || '거래내역';
const isUsd = String(opts.currency || '').toUpperCase() === 'USD';
const fx = Number(opts.fxRate || 0);
const fmtMoney = (n) => isUsd ? fmtUsdWithKrwText(n, fx) : (fmtWon(n) + '원');
let line = `📋 ${label} · 한도 ${tb > 0 ? (isUsd ? fmtUsd(tb) : fmtWon(tb) + '원') : '—'}`;
if (peak > 0 && peakAt) {
line += ` | 장중 누적 최고 <b style="color:var(--green)">+${fmtMoney(peak)}</b> (${peakAt})`;
}
if (opts.totalPnl != null && Number.isFinite(Number(opts.totalPnl))) {
const pnlHtml = isUsd
? fmtUsdWithKrw(opts.totalPnl, fx > 0 ? fx : (Number(opts.totalPnlKrw) ? (Number(opts.totalPnl) ? Number(opts.totalPnlKrw) / Number(opts.totalPnl) : fx) : fx))
: fmtKrw(opts.totalPnl);
// 원 환산 금액이 별도로 있으면 그걸로 병기
if (isUsd && opts.totalPnlKrw != null && Number.isFinite(Number(opts.totalPnlKrw))) {
line += ` | 최종 누적 <b>${fmtUsd(opts.totalPnl)} <span style="color:var(--muted);font-size:11px">(${fmtWon(opts.totalPnlKrw)}원)</span></b>`;
} else {
line += ` | 최종 누적 <b>${pnlHtml}</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;
}
/** KR 6자리 종목코드 */
function _normKrStockCode(code) {
const d = String(code || '').replace(/\D/g, '');
if (!d) return '';
return d.length >= 6 ? d.slice(-6) : d.padStart(6, '0');
}
function _tradeExtLinksHtml(r) {
if (r.isUsd || !r.code) return '';
const c = _normKrStockCode(r.code);
return `
<div style="margin-top:2px; display:flex; gap:3px;">
<a href="https://finance.naver.com/item/main.naver?code=${c}" target="_blank" class="btn btn-xs btn-outline-secondary" style="font-size:9px;padding:0 3px">N</a>
<a href="https://m.stock.naver.com/domestic/stock/${c}/total" target="_blank" class="btn btn-xs btn-outline-secondary" style="font-size:9px;padding:0 3px">N(M)</a>
</div>
`;
}
/** 호가셀 — enrich 는 ratio(L3잔량비)·or_ratio(전체잔량비). 구키 mid_ratio/whale_ratio 도 허용. */
function _obMwCellHtml(ob) {
if (!ob) return '-';
const mr = Number(ob.mid_ratio != null ? ob.mid_ratio : ob.ratio);
const wr = Number(ob.whale_ratio != null ? ob.whale_ratio : ob.or_ratio);
const m = Number.isFinite(mr) ? mr.toFixed(2) : '-';
const w = Number.isFinite(wr) ? wr.toFixed(2) : '-';
// 잔량비(배수) — % 아님 (0.68 = 매수L3/매도L3)
return `M:${m}<br>W:${w}`;
}
function _entryObCellHtml(ob) {
return _obMwCellHtml(ob);
}
function _exitObCellHtml(ob, isOpen) {
if (isOpen) return '<span style="color:#2ea043">보유중</span>';
return _obMwCellHtml(ob);
}
function renderVirtualTrades(tbodyId, trades, opts) {
opts = opts || {};
let src = trades || [];
if (opts.showCumulative) {
src = attachCumPnlClient(src, opts.totalBudget);
}
_virtualTradesCache[tbodyId] = src;
_virtualTradesOpts[tbodyId] = opts;
if (!_virtualTradesSort[tbodyId]) _virtualTradesSort[tbodyId] = 'exit_desc';
const mode = _virtualTradesSort[tbodyId];
const meta = Object.assign({}, opts.meta || {}, {
currency: opts.currency || (opts.meta && opts.meta.currency) || '',
fxRates: opts.fxRates || (opts.meta && opts.meta.fxRates) || {},
});
const isUsd = String(meta.currency || '').toUpperCase() === 'USD';
const defaultFx = getDisplayFxRate(meta.fxRate || opts.fxRate);
const fmtPx = (n, fx) => isUsd ? fmtUsdWithKrw(n, getDisplayFxRate(fx || defaultFx)) : fmtWon(n);
const fmtPnlCell = (n, fx) => isUsd ? fmtUsdWithKrw(n, getDisplayFxRate(fx || defaultFx)) : fmtWon(n);
const rows = sortVirtualTradeRows(
(_virtualTradesCache[tbodyId] || []).map(t => normalizeVirtualTrade(t, Object.assign({}, meta, { fxRate: defaultFx }))),
mode,
);
const tbody = $(tbodyId);
if (!tbody) return;
tbody.innerHTML = '';
rows.forEach(r => {
const pnlCls = r.pnl > 0 ? 'text-pnl-pos' : (r.pnl < 0 ? 'text-pnl-neg' : '');
const rateBadge = r.isOpen
? 'badge-flat'
: (r.rateNum > 0 ? 'badge-win' : (r.rateNum < 0 ? 'badge-loss' : 'badge-flat'));
const nameCell = (r.name || r.code)
? `${r.name || r.code}<br><span style="color:var(--muted);font-size:11px">${r.code || ''}</span>`
: (r.code || '-');
const linksHtml = _tradeExtLinksHtml(r);
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 rowFx = getDisplayFxRate(r.fxRate || defaultFx);
const cumPnlHtml = Number.isFinite(r.cumPnl)
? `<span class="${r.cumPnl > 0 ? 'text-pnl-pos' : (r.cumPnl < 0 ? 'text-pnl-neg' : '')}">${fmtPnlCell(r.cumPnl, rowFx)}</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
? `${fmtPx(r.xp, rowFx)} <span style="color:var(--muted);font-size:10px">현재가</span>`
: fmtPx(r.xp, rowFx);
const pnlCell = r.isOpen
? `${fmtPnlCell(r.pnl, rowFx)} <span style="color:var(--muted);font-size:10px">평가</span>`
: fmtPnlCell(r.pnl, rowFx);
const isParityEx = !!r.parityException;
const rowCls = [
r.isOpen ? 'trade-row-open' : '',
isParityEx ? 'trade-row-parity-ex' : '',
].filter(Boolean).join(' ');
const causeBadge = isParityEx
? `<span class="parity-cause-corner" title="정합 예외 경로">${r.parityCause || '예외'}</span>`
: '';
const rowStyle = r.isOpen && !isParityEx ? ' style="background:rgba(210,153,34,.06)"' : '';
const liveAttrs = r.isOpen && r.code && r.ep > 0 && r.qty > 0
? ` data-live-code="${String(r.code).replace(/"/g, '')}" data-live-buy="${r.ep}" data-live-qty="${r.qty}" data-live-usd="${r.isUsd ? '1' : '0'}"`
: '';
let html = `<tr class="${rowCls}"${rowStyle}${liveAttrs}>
<td class="trade-name-cell">${causeBadge}${nameCell}${linksHtml}</td>
<td style="font-size:11px">${fmtTradeTime(r.buyRaw)}</td>
<td style="font-size:11px">${sellCell}</td>
<td>${fmtPx(r.ep, rowFx)}</td>
<td data-live-cell="px">${exitPxCell}</td>
<td>${r.qty}</td>
<td class="${pnlCls}" data-live-cell="pnl">${pnlCell}</td>
<td><span class="${rateBadge}" data-live-cell="pct">${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:10px;line-height:1.35">${_entryObCellHtml(r.entryOb)}</td>`;
html += `<td style="font-size:10px;line-height:1.35">${_exitObCellHtml(r.exitOb, r.isOpen)}</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);
if (rows.some(r => r.isOpen)) {
liveQuoteStartFromDom(tbody);
}
}
function setVirtualTradesSort(tbodyId, mode) {
_virtualTradesSort[tbodyId] = mode;
if (_virtualTradesCache[tbodyId]) {
renderVirtualTrades(tbodyId, _virtualTradesCache[tbodyId], _virtualTradesOpts[tbodyId] || {});
}
}
document.addEventListener('click', e => {
const btn = e.target.closest('.trade-sort-btn');
if (!btn) return;
const bar = btn.closest('.trade-sort-bar');
if (!bar || !bar.dataset.tbody) return;
setVirtualTradesSort(bar.dataset.tbody, btn.dataset.sort || 'exit_desc');
});
const colorPnl = (el, val) => {
if (!el) return;
el.classList.remove('green','red');
if (val > 0) el.classList.add('green');
else if (val < 0) el.classList.add('red');
};
// 차트 인스턴스 저장 (재생성용)
const charts = {};
function destroyChart(id) {
if (charts[id]) { charts[id].destroy(); delete charts[id]; }
}
const CHART_COLORS = {
blue: '#58a6ff', green: '#3fb950', red: '#f85149',
yellow: '#d29922', purple:'#bc8cff', orange:'#f0883e',
};
function lineChart(id, labels, data, label, color) {
destroyChart(id);
const el = $(id);
if (!el) return;
const ctx = el.getContext('2d');
if (!ctx) return;
charts[id] = new Chart(ctx, {
type: 'line',
data: {
labels,
datasets: [{
label, data,
borderColor: color || CHART_COLORS.blue,
borderWidth: 2,
pointRadius: data.length > 100 ? 0 : 3,
fill: { target: 'origin', above: 'rgba(63,185,80,0.08)', below: 'rgba(248,81,73,0.08)' },
tension: 0.3,
}]
},
options: {
responsive: true, animation: false,
plugins: { legend: { display: false },
tooltip: { callbacks: { label: ctx => fmt(ctx.parsed.y) + '원' } } },
scales: {
x: { ticks: { color: '#8b949e', maxTicksLimit: 12 }, grid: { color:'#21262d' } },
y: { ticks: { color: '#8b949e', callback: v => fmt(v) }, grid: { color:'#21262d' } },
}
}
});
}
function barChart(id, labels, data, colors) {
destroyChart(id);
const el = $(id);
if (!el) return;
const ctx = el.getContext('2d');
if (!ctx) return;
charts[id] = new Chart(ctx, {
type: 'bar',
data: {
labels,
datasets: [{
data,
backgroundColor: colors || data.map(v => v >= 0 ? 'rgba(63,185,80,0.7)' : 'rgba(248,81,73,0.7)'),
}]
},
options: {
responsive: true, animation: false,
plugins: { legend: { display: false },
tooltip: { callbacks: { label: ctx => fmt(ctx.parsed.y) + '원' } } },
scales: {
x: { ticks: { color: '#8b949e', maxTicksLimit: 14 }, grid: { color:'#21262d' } },
y: { ticks: { color: '#8b949e', callback: v => fmt(v) }, grid: { color:'#21262d' } },
}
}
});
}
function doughnutChart(id, labels, data) {
destroyChart(id);
const el = $(id);
if (!el) return;
const ctx = el.getContext('2d');
if (!ctx) return;
const palette = [CHART_COLORS.green, CHART_COLORS.red, CHART_COLORS.yellow,
CHART_COLORS.purple, CHART_COLORS.orange, CHART_COLORS.blue];
charts[id] = new Chart(ctx, {
type: 'doughnut',
data: {
labels,
datasets: [{ data, backgroundColor: palette, borderWidth: 0 }]
},
options: {
responsive: true, animation: false,
plugins: { legend: { position: 'right', labels: { color:'#8b949e', font:{ size:11 } } } }
}
});
}
// ────────────────────────────────────────────
// 탭 전환
// ────────────────────────────────────────────
document.querySelectorAll('[data-tab]').forEach(el => {
el.addEventListener('click', e => {
e.preventDefault();
document.querySelectorAll('[data-tab]').forEach(x => x.classList.remove('active'));
el.classList.add('active');
const tab = el.dataset.tab;
$('tab-actual').style.display = tab === 'actual' ? '' : 'none';
$('tab-dashboard').style.display = tab === 'dashboard'? '' : 'none';
$('tab-portfolio').style.display = tab === 'portfolio'? '' : 'none';
$('tab-backtest').style.display = tab === 'backtest' ? '' : 'none';
$('tab-tail').style.display = tab === 'tail' ? '' : 'none';
$('tab-dbband').style.display = tab === 'dbband' ? '' : 'none';
$('tab-breakout').style.display = tab === 'breakout' ? '' : 'none';
$('tab-range_break').style.display = tab === 'range_break' ? '' : 'none';
$('tab-momentum').style.display = tab === 'momentum' ? '' : 'none';
$('tab-us_momentum').style.display = tab === 'us_momentum' ? '' : 'none';
$('tab-optuna').style.display = tab === 'optuna' ? '' : 'none';
$('tab-holding').style.display = tab === 'holding' ? '' : 'none';
$('tab-updownbox').style.display = tab === 'updownbox'? '' : 'none';
$('tab-updow').style.display = tab === 'updow' ? '' : 'none';
$('tab-dart').style.display = tab === 'dart' ? '' : 'none';
$('tab-liveconfig').style.display = tab === 'liveconfig' ? '' : 'none';
if (tab === 'dashboard') loadDashboard();
if (tab === 'liveconfig') lcOnTabShow();
if (tab === 'portfolio') pfLoad(true);
if (tab === 'holding') hdLoadStocks();
if (tab === 'updow') permLoad();
if (tab === 'us_momentum') {
updateUsMomFxHint();
usmomLoadStocks(false);
usmomOptSyncDatesFromMain();
if (_usmomOptJobId) usmomStockOptunaStartPolling();
}
if (tab === 'dart') dartOnTabShow();
if (tab === 'updownbox') ubxOnTabShow();
if (tab === 'dbband') dbLoadStocks();
if (tab === 'breakout' && typeof boSyncSlModeColors === 'function') boSyncSlModeColors();
if (tab === 'optuna') optunaOnTabShow();
// 보유 라이브 시세: 보유·실거래 탭에서만 SSE/폴링 (다른 탭 이탈 시 중지)
if (tab === 'portfolio') {
/* pfRenderTable 에서 start */
} else if (tab === 'actual') {
const at = $('act_tbody');
if (at && at.querySelector('[data-live-code]')) liveQuoteStartFromDom(at);
else liveQuoteStop();
} else {
liveQuoteStop();
}
});
});
// ════════════════════════════════════════════════════════════════
// DART 수주 공시
// ════════════════════════════════════════════════════════════════
let _dartPollTimer = null;
function dartOnTabShow() {
dartLoad();
if (_dartPollTimer) clearInterval(_dartPollTimer);
_dartPollTimer = setInterval(() => {
const t = document.querySelector('[data-tab].active');
if (t && t.dataset.tab === 'dart') dartLoad(true);
}, 10000);
}
async function dartLoad(quiet) {
try {
const r = await fetch('/api/dart/disclosures?limit=80');
const j = await r.json();
if (!j.ok) throw new Error(j.error || 'fail');
const f = j.flags || {};
if ($('dart_scan_en')) $('dart_scan_en').checked = !!f.scan;
if ($('dart_sub_en')) $('dart_sub_en').checked = !!f.subscribe;
if ($('dart_trade_en')) $('dart_trade_en').checked = !!f.trade;
if ($('dart_strat_en')) $('dart_strat_en').checked = !!f.strategy;
if ($('dart_watch_max') && f.watch_max != null) $('dart_watch_max').value = f.watch_max;
if ($('dart_watch_ttl') && f.watch_ttl_hours != null) $('dart_watch_ttl').value = f.watch_ttl_hours;
if ($('dart_quality_en')) $('dart_quality_en').checked = f.quality_filter !== false;
if ($('dart_min_sales') && f.min_sales_pct != null) $('dart_min_sales').value = f.min_sales_pct;
if ($('dart_require_theme')) $('dart_require_theme').checked = f.require_theme !== false;
const wb = $('dart_watch_tbody');
if (wb) {
const w = j.watch || [];
wb.innerHTML = w.length ? w.map(x => `<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)
// ════════════════════════════════════════════════════════════════
let _permRefreshTimer = null;
let _permRefreshSec = 15;
function permOnMarket() {
// 시장 선택 시 거래소 기본값 자동 보정 (KR→KRX, US→NASD)
const mk = $('perm_market') ? $('perm_market').value : 'KR';
if ($('perm_exch')) $('perm_exch').value = (mk === 'US') ? 'NASD' : 'KRX';
}
function permFmtQuoteTime(t) {
const s = String(t || '');
if (s.length >= 12) {
return s.slice(4, 6) + '/' + s.slice(6, 8) + ' ' + s.slice(8, 10) + ':' + s.slice(10, 12);
}
if (s.length >= 8) return s.slice(0, 4) + '-' + s.slice(4, 6) + '-' + s.slice(6, 8);
return s;
}
function permFmtPriceCell(x, fxRate) {
const px = Number(x.price);
if (!(px > 0)) {
return '<span class="text-muted">—</span><br><span style="color:var(--muted);font-size:10px">시세대기</span>';
}
const isUs = String(x.market_type || '').toUpperCase() === 'US';
const fx = getDisplayFxRate(fxRate != null ? fxRate : x.fx_rate);
let main;
if (isUs) {
const krw = (x.price_krw != null && Number(x.price_krw) > 0)
? Number(x.price_krw)
: (fx > 0 ? Math.round(px * fx) : 0);
main = '<b>$' + px.toFixed(4) + '</b>';
if (krw > 0) {
main += '<br><span style="color:var(--muted);font-size:11px">('
+ Math.round(krw).toLocaleString('ko-KR') + '원'
+ (fx > 0 ? (' · ' + fx.toFixed(2)) : '')
+ ')</span>';
}
} else {
main = '<b>' + Math.round(px).toLocaleString('ko-KR') + '</b>';
}
let chgHtml = '';
const chg = x.chg_pct;
if (chg != null && chg !== '' && Number.isFinite(Number(chg))) {
const v = Number(chg);
const cls = v >= 0 ? 'text-pnl-pos' : 'text-pnl-neg';
chgHtml = '<br><span class="' + cls + '" style="font-size:11px">'
+ (v >= 0 ? '+' : '') + v.toFixed(2) + '%</span>';
}
const t = (x.price_src === 'tick' && x.tick_time)
? x.tick_time
: (x.candle_time || x.tick_time || '');
const src = (x.price_src === 'tick') ? 'tick' : (x.price_src === 'candle' ? '봉' : '');
const meta = permFmtQuoteTime(t) + (src ? (' ·' + src) : '');
return main + chgHtml
+ (meta ? ('<br><span style="color:var(--muted);font-size:10px">' + meta + '</span>') : '');
}
function permEnsureAutoRefresh(sec) {
const s = Math.max(5, Number(sec) || _permRefreshSec || 15);
_permRefreshSec = s;
if (_permRefreshTimer) clearInterval(_permRefreshTimer);
_permRefreshTimer = setInterval(function () {
const tab = $('tab-updow');
if (tab && tab.style.display !== 'none') permLoad({ quiet: true });
}, s * 1000);
}
async function permLoad(opt) {
const quiet = !!(opt && opt.quiet);
const tb = $('perm_tbody');
if (!tb) return;
try {
const url = quiet ? '/api/permanent_subs?light=1' : '/api/permanent_subs';
const r = await fetch(url);
const j = await r.json();
const rows = (j && j.rows) || [];
const baseTf = Number((j && j.base_tf_min) || 1) || 1;
if (j && j.fx_rate) setDisplayFxRate(j.fx_rate);
if (j && j.refresh_sec) permEnsureAutoRefresh(j.refresh_sec);
const fx = getDisplayFxRate(j && j.fx_rate);
if (!rows.length) {
tb.innerHTML = '<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>';
const nm = (x.name || '').trim();
const code = x.code || '';
const title = nm && nm !== code
? ('<b>' + code + '</b><br><span style="color:var(--muted);font-size:11px">' + nm + '</span>')
: ('<b>' + code + '</b>');
const editPayload = JSON.stringify({
code: x.code,
market_type: x.market_type,
exchange: x.exchange,
symbol: x.symbol,
note: x.note || '',
});
return '<tr>'
+ '<td>' + title + '</td>'
+ '<td style="white-space:nowrap">' + permFmtPriceCell(x, fx) + '</td>'
+ '<td>' + mkBadge + '</td>'
+ '<td>' + (x.exchange || '') + '</td>'
+ '<td><span class="badge bg-dark">' + baseTf + '분</span></td>'
+ '<td><code style="font-size:11px">' + (x.ws_tr_key || '') + '</code></td>'
+ '<td>' + onBadge + '</td>'
+ '<td style="font-size:11px">' + (x.note || '') + '</td>'
+ '<td><button class="btn btn-sm btn-outline-secondary" onclick=\'permEdit(' + JSON.stringify(editPayload) + ')\'>수정</button> '
+ '<button class="btn btn-sm btn-outline-danger" onclick="permDelete(\'' + code + '\')">삭제</button></td>'
+ '</tr>';
}).join('');
} catch (e) {
if (!quiet) {
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_note')) $('perm_note').value = x.note || '';
permOnMarket();
}
async function permSave() {
const code = ($('perm_code') ? $('perm_code').value : '').trim().toUpperCase();
if (!code) { alert('코드를 입력하세요'); return; }
const payload = {
code: code,
market_type: $('perm_market') ? $('perm_market').value : 'KR',
exchange: $('perm_exch') ? $('perm_exch').value : '',
symbol: $('perm_symbol') ? $('perm_symbol').value.trim().toUpperCase() : '',
enabled: true,
note: $('perm_note') ? $('perm_note').value : '',
};
try {
const r = await fetch('/api/permanent_subs/save', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
});
const j = await r.json();
if (!j.ok) { alert('저장 실패: ' + (j.error || '')); return; }
if ($('perm_code')) $('perm_code').value = '';
if ($('perm_symbol')) $('perm_symbol').value = '';
if ($('perm_note')) $('perm_note').value = '';
permLoad();
} catch (e) { alert('저장 오류: ' + e); }
}
async function permDelete(code) {
if (!confirm(code + ' 영구구독을 삭제할까요?')) return;
try {
const r = await fetch('/api/permanent_subs/delete', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code: code }),
});
const j = await r.json();
if (!j.ok && j.error) { alert('삭제 실패: ' + j.error); }
permLoad();
} catch (e) { alert('삭제 오류: ' + e); }
}
// ────────────────────────────────────────────
// 오늘 운영 대시보드
// ────────────────────────────────────────────
function dashFmtKrw(n) {
return fmtKrw(n);
}
function dashFmtPct(n) {
const v = Number(n) || 0;
return (v >= 0 ? '+' : '') + v.toFixed(2) + '%';
}
function dashPnlCls(n) {
return Number(n) >= 0 ? 'text-pnl-pos' : 'text-pnl-neg';
}
function dashInitDate() {
const el = $('dash_date');
if (!el) return;
if (!el.value) {
el.value = kstTradingDayIso(_krHolidays);
} else {
el.value = kstClampToPrevTradingDayIso(el.value, _krHolidays);
}
}
async function loadDashboard() {
dashInitDate();
const day = $('dash_date').value;
showSpinner(true);
try {
const r = await fetch('/api/actual/dashboard?date=' + encodeURIComponent(day));
const d = await r.json();
if (!d.ok) { alert(d.error || '대시보드 조회 실패'); return; }
const tot = d.totals || {};
$('dash_meta').textContent = '기준일 ' + d.date + ' · 갱신 ' + (d.as_of || '');
$('dash_card_turnover').textContent = dashFmtKrw(tot.turnover_krw);
$('dash_card_turnover_sub').textContent =
'매수 ' + dashFmtKrw(tot.buy_turnover_krw) + ' · 매도 ' + dashFmtKrw(tot.sell_turnover_krw);
$('dash_card_budget').textContent = dashFmtPct(tot.budget_usage_pct);
$('dash_card_budget_sub').textContent =
'피크 ' + dashFmtKrw(tot.budget_used_krw) + ' · 현재 ' + dashFmtKrw(tot.budget_now_krw || 0)
+ ' / 한도 ' + dashFmtKrw(tot.budget_limit_krw);
$('dash_card_pnl').textContent = dashFmtKrw(tot.realized_pnl_krw);
$('dash_card_pnl').className = 'stat-value ' + dashPnlCls(tot.realized_pnl_krw);
$('dash_card_ret').textContent = dashFmtPct(tot.return_pct);
$('dash_card_ret').className = 'stat-value ' + dashPnlCls(tot.return_pct);
const tbody = $('dash_tbody');
tbody.innerHTML = '';
let prevSidOps = '';
(d.strategies || []).forEach(row => {
const sid = row.strategy_id;
const src = row.source || '';
const isLive = !!row.is_live_source;
const onBadge = !src
? (row.enabled ? '<span class="badge-win">ON</span>' : '<span class="badge-loss">OFF</span>')
: (isLive
? (row.enabled ? '<span class="badge-win">ON</span>' : '<span class="badge-loss">OFF</span>')
: '<span class="text-muted">—</span>');
const srcCls = src === 'ls' ? 'dash-univ-src-ls' : 'dash-univ-src-kiwoom';
const srcCell = src
? `<span class="${srcCls}">${row.source_label || src}</span>`
+ (isLive ? ' <span class="badge-win" style="font-size:10px">실매</span>' : '')
: '—';
const pnlCls = dashPnlCls(row.realized_pnl_krw);
const retCls = dashPnlCls(row.return_pct);
const trCls = [
prevSidOps && prevSidOps !== sid ? 'dash-ops-group-start' : '',
src && !isLive ? 'dash-ops-inactive' : '',
].filter(Boolean).join(' ');
prevSidOps = sid;
const mute = src && !isLive;
const fmtOrDash = (v, fn) => (mute ? '—' : fn(v));
tbody.insertAdjacentHTML('beforeend', `
<tr class="${trCls}">
<td>${row.label || sid}<br><code style="font-size:11px">${sid}</code></td>
<td>${srcCell}</td>
<td>${onBadge}</td>
<td class="text-end">${fmtOrDash(row.buy_turnover_krw, dashFmtKrw)}</td>
<td class="text-end">${fmtOrDash(row.sell_turnover_krw, dashFmtKrw)}</td>
<td class="text-end">${fmtOrDash(row.turnover_krw, dashFmtKrw)}</td>
<td class="text-end">${mute ? '—' : row.closed_trades}</td>
<td class="text-end">${mute ? '—' : row.open_positions}</td>
<td class="text-end ${mute ? '' : pnlCls}">${fmtOrDash(row.realized_pnl_krw, dashFmtKrw)}</td>
<td class="text-end">${fmtOrDash(row.budget_limit_krw, dashFmtKrw)}</td>
<td class="text-end">${fmtOrDash(row.budget_used_krw, dashFmtKrw)}</td>
<td class="text-end text-muted">${fmtOrDash(row.budget_now_krw || 0, dashFmtKrw)}</td>
<td class="text-end">${fmtOrDash(row.budget_usage_pct, dashFmtPct)}</td>
<td class="text-end ${mute ? '' : retCls}">${fmtOrDash(row.return_pct, dashFmtPct)}</td>
<td>${isLive || !src
? `<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.strategy_source || '') + ' · '
+ (notes.turnover || '') + ' · ' + (notes.budget_used || '') + ' · '
+ (notes.budget_now || '') + ' · ' + (notes.return_pct || '');
// 조건검색 이력: 전략별 키움 / LS 두 줄
const univBody = $('dash_univ_tbody');
if (univBody) {
univBody.innerHTML = '';
const univRows = d.universe_history || [];
let prevSid = '';
univRows.forEach(row => {
const sid = row.strategy_id || '';
const src = row.source || '';
const srcCls = src === 'ls' ? 'dash-univ-src-ls' : 'dash-univ-src-kiwoom';
const liveBadge = row.is_live_source
? ' <span class="badge-win" style="font-size:10px">실매</span>'
: '';
const liveSrc = row.live_universe_source || '—';
const lastAt = row.last_at || '—';
const firstAt = row.first_at || '—';
const stale = !!row.stale;
const trCls = [
prevSid && prevSid !== sid ? 'dash-univ-group-start' : '',
stale ? 'dash-univ-stale' : '',
].filter(Boolean).join(' ');
prevSid = sid;
univBody.insertAdjacentHTML('beforeend', `
<tr class="${trCls}">
<td>${row.label || sid}<br><code style="font-size:11px">${sid}</code></td>
<td><span class="${srcCls}">${row.source_label || src}</span>${liveBadge}</td>
<td><code style="font-size:11px">${liveSrc}</code></td>
<td class="text-end">${Number(row.slots || 0).toLocaleString()}</td>
<td class="text-end">${Number(row.codes || 0).toLocaleString()}</td>
<td class="text-end">${Number(row.rows || 0).toLocaleString()}</td>
<td style="font-size:11px;white-space:nowrap">${firstAt}</td>
<td style="font-size:11px;white-space:nowrap">${lastAt}${stale
? ' <span class="badge-loss" style="font-size:9px">장전끊</span>' : ''}</td>
</tr>`);
});
const histOn = d.ls_condition_history_enabled;
const meta = $('dash_univ_meta');
if (meta) {
meta.textContent = histOn
? 'LS_CONDITION_HISTORY_ENABLED=ON'
: 'LS_CONDITION_HISTORY_ENABLED=OFF (LS 적재 확인)';
}
const un = $('dash_univ_notes');
if (un) {
un.textContent = (notes.universe_history || '')
+ ' · 키움=target_candidates_history · LS=ls_candidates_history'
+ (staleNoteFromUniv(univRows) || '');
}
}
} catch (e) {
alert('대시보드 오류: ' + e);
} finally {
showSpinner(false);
}
}
function staleNoteFromUniv(rows) {
const lsStale = (rows || []).filter(r => r.source === 'ls' && r.stale);
if (!lsStale.length) return '';
return ' · ⚠ LS 이력이 장시작 전에서 끊김 — 장중 적재 점검 필요';
}
function dashDrill(strategyId) {
const radio = document.querySelector('input[name=act_strategy][value="' + strategyId + '"]');
if (radio) radio.checked = true;
const day = $('dash_date').value;
if (day) {
$('act_start').value = day;
$('act_end').value = day;
}
document.querySelectorAll('[data-tab]').forEach(x => x.classList.remove('active'));
const actTab = document.querySelector('[data-tab="actual"]');
if (actTab) actTab.classList.add('active');
$('tab-dashboard').style.display = 'none';
$('tab-actual').style.display = '';
$('tab-portfolio').style.display = 'none';
$('tab-backtest').style.display = 'none';
$('tab-tail').style.display = 'none';
$('tab-breakout').style.display = 'none';
$('tab-range_break').style.display = 'none';
$('tab-momentum').style.display = 'none';
$('tab-us_momentum').style.display = 'none';
$('tab-holding').style.display = 'none';
$('tab-updow').style.display = 'none';
loadActual();
}
function dashGoActual() {
dashDrill(document.querySelector('input[name=act_strategy]:checked')?.value || 'SHORT');
}
dashInitDate();
// ────────────────────────────────────────────
// 보유중 라이브 시세 (ws_ticks SELECT via API — 매매 비접촉)
// ────────────────────────────────────────────
let _liveQuoteES = null;
let _liveQuotePoll = null;
let _liveQuotePrevPx = {};
let _liveQuoteCodesKey = '';
function liveQuoteStop() {
if (_liveQuoteES) {
try { _liveQuoteES.close(); } catch (e) { /* ignore */ }
_liveQuoteES = null;
}
if (_liveQuotePoll) {
clearInterval(_liveQuotePoll);
_liveQuotePoll = null;
}
_liveQuoteCodesKey = '';
}
function liveQuoteCollectCodes(root) {
const scope = root || document;
const codes = [];
const seen = new Set();
scope.querySelectorAll('[data-live-code]').forEach(el => {
const c = String(el.getAttribute('data-live-code') || '').trim();
if (!c || seen.has(c)) return;
seen.add(c);
codes.push(c);
});
return codes;
}
function liveQuoteFlash(el, dir) {
if (!el) return;
el.classList.remove('live-quote-flash-up', 'live-quote-flash-down');
void el.offsetWidth;
el.classList.add(dir > 0 ? 'live-quote-flash-up' : 'live-quote-flash-down');
}
function liveQuoteFmtPx(px, isUsd) {
const n = Number(px);
if (!Number.isFinite(n) || n <= 0) return '—';
if (isUsd) return fmtUsdWithKrw(n, getDisplayFxRate());
return fmtWon(n);
}
function liveQuoteFmtPnl(pnl, isUsd) {
if (!Number.isFinite(pnl)) return '—';
if (isUsd) return fmtUsdWithKrw(pnl, getDisplayFxRate());
return fmtWon(pnl);
}
function liveQuoteFmtPctNum(pct) {
if (!Number.isFinite(pct)) return '—';
const sign = pct >= 0 ? '+' : '';
return `${sign}${pct.toFixed(2)}%`;
}
function liveQuoteApplyPctBadge(el, pct) {
if (!el) return;
el.classList.remove('badge-win', 'badge-loss', 'badge-flat', 'text-pnl-pos', 'text-pnl-neg');
if (!Number.isFinite(pct)) {
if (el.tagName === 'SPAN') el.classList.add('badge-flat');
return;
}
if (el.tagName === 'SPAN') {
el.classList.add(pct > 0 ? 'badge-win' : (pct < 0 ? 'badge-loss' : 'badge-flat'));
} else {
el.classList.add(pct >= 0 ? 'text-pnl-pos' : 'text-pnl-neg');
}
}
function liveQuoteApplyPayload(d) {
if (!d || !d.ok || d.enabled === false) return;
const quotes = d.quotes || {};
document.querySelectorAll('tr[data-live-code]').forEach(tr => {
const code = String(tr.getAttribute('data-live-code') || '').trim();
const q = quotes[code];
if (!q || !(Number(q.price) > 0)) return;
const px = Number(q.price);
const buy = Number(tr.getAttribute('data-live-buy') || 0);
const qty = Number(tr.getAttribute('data-live-qty') || 0);
const isUsd = String(tr.getAttribute('data-live-usd') || '') === '1';
const prev = _liveQuotePrevPx[code];
const pxEl = tr.querySelector('[data-live-cell="px"]');
const pnlEl = tr.querySelector('[data-live-cell="pnl"]');
const pctEl = tr.querySelector('[data-live-cell="pct"]');
const dayEl = tr.querySelector('[data-live-cell="day"]');
const evalEl = tr.querySelector('[data-live-cell="eval"]');
if (pxEl) {
pxEl.innerHTML = liveQuoteFmtPx(px, isUsd);
if (prev != null && Number.isFinite(prev) && prev > 0 && px !== prev) {
liveQuoteFlash(pxEl, px > prev ? 1 : -1);
}
pxEl.classList.toggle('live-quote-stale', !!q.stale);
}
const pnl = (buy > 0 && qty > 0) ? (px - buy) * qty : NaN;
const buyPct = buy > 0 ? ((px - buy) / buy) * 100 : NaN;
const dayPct = (q.day_chg_pct != null && Number.isFinite(Number(q.day_chg_pct)))
? Number(q.day_chg_pct) : NaN;
if (pnlEl && Number.isFinite(pnl)) {
const label = tr.classList.contains('trade-row-open')
? `${liveQuoteFmtPnl(pnl, isUsd)} <span style="color:var(--muted);font-size:10px">평가</span>`
: liveQuoteFmtPnl(pnl, isUsd);
pnlEl.innerHTML = label;
pnlEl.classList.toggle('text-pnl-pos', pnl > 0);
pnlEl.classList.toggle('text-pnl-neg', pnl < 0);
}
// 당일 등락(전일종가 대비)=메인, 매수가 대비=보조
if (dayEl) {
dayEl.textContent = liveQuoteFmtPctNum(dayPct);
liveQuoteApplyPctBadge(dayEl, dayPct);
if (prev != null && Number.isFinite(prev) && px !== prev) {
liveQuoteFlash(dayEl, px > prev ? 1 : -1);
}
}
if (pctEl) {
if (dayEl) {
pctEl.innerHTML = Number.isFinite(buyPct)
? `<span style="font-size:11px">${liveQuoteFmtPctNum(buyPct)}</span>`
+ `<div style="color:var(--muted);font-size:9px;line-height:1.2">매수대비</div>`
: '—';
liveQuoteApplyPctBadge(pctEl, buyPct);
} else {
pctEl.innerHTML = Number.isFinite(dayPct)
? `<div style="font-weight:700">${liveQuoteFmtPctNum(dayPct)}</div>`
+ `<div style="color:var(--muted);font-size:10px;line-height:1.2">당일`
+ (Number.isFinite(buyPct) ? ` · 매수 ${liveQuoteFmtPctNum(buyPct)}` : '')
+ `</div>`
: (Number.isFinite(buyPct)
? `${liveQuoteFmtPctNum(buyPct)}<div style="color:var(--muted);font-size:9px">매수대비</div>`
: '—');
liveQuoteApplyPctBadge(pctEl, Number.isFinite(dayPct) ? dayPct : buyPct);
if (prev != null && Number.isFinite(prev) && px !== prev) {
liveQuoteFlash(pctEl, px > prev ? 1 : -1);
}
}
}
if (evalEl && qty > 0) {
evalEl.innerHTML = liveQuoteFmtPx(px * qty, isUsd);
}
_liveQuotePrevPx[code] = px;
});
}
function liveQuoteStartFromDom(root) {
const codes = liveQuoteCollectCodes(root);
const key = codes.slice().sort().join(',');
if (!codes.length) {
liveQuoteStop();
return;
}
if (key === _liveQuoteCodesKey && (_liveQuoteES || _liveQuotePoll)) {
return;
}
liveQuoteStop();
_liveQuoteCodesKey = key;
const q = 'codes=' + encodeURIComponent(codes.join(','));
const url = '/api/portfolio/live_quotes_stream?' + q;
if (typeof EventSource !== 'undefined') {
try {
_liveQuoteES = new EventSource(url);
_liveQuoteES.onmessage = (ev) => {
try {
liveQuoteApplyPayload(JSON.parse(ev.data));
} catch (e) { /* ignore */ }
};
_liveQuoteES.onerror = () => {
if (_liveQuoteES) {
try { _liveQuoteES.close(); } catch (e2) { /* ignore */ }
_liveQuoteES = null;
}
if (!_liveQuotePoll) {
_liveQuotePoll = setInterval(() => {
fetch('/api/portfolio/live_quotes?' + q)
.then(r => r.json())
.then(liveQuoteApplyPayload)
.catch(() => {});
}, 500);
}
};
return;
} catch (e) { /* fall through */ }
}
_liveQuotePoll = setInterval(() => {
fetch('/api/portfolio/live_quotes?' + q)
.then(r => r.json())
.then(liveQuoteApplyPayload)
.catch(() => {});
}, 400);
}
// ────────────────────────────────────────────
// 보유·매도 (active_trades + OrderManager 시장가)
// ────────────────────────────────────────────
// 보유·매도 정렬 상태 (다른 탭의 정렬바와 동일 UX, 데이터 스키마만 다름)
let _pfItems = [];
let _pfMeta = null;
let _pfSort = 'pnl_desc';
/** 보유·매도 정렬 — 수익률/평가금/종목명/미등록 우선 */
function pfSortItems(items, mode) {
const arr = [...(items || [])];
const num = v => (Number.isFinite(Number(v)) ? Number(v) : 0);
switch (mode) {
case 'pnl_asc': return arr.sort((a, b) => num(a.pnl_pct) - num(b.pnl_pct));
case 'eval_desc': return arr.sort((a, b) => num(b.eval_amt) - num(a.eval_amt));
case 'eval_asc': return arr.sort((a, b) => num(a.eval_amt) - num(b.eval_amt));
case 'name_asc': return arr.sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''), 'ko'));
case 'untracked_first':
return arr.sort((a, b) => (b.untracked ? 1 : 0) - (a.untracked ? 1 : 0) || num(b.eval_amt) - num(a.eval_amt));
case 'pnl_desc':
default: return arr.sort((a, b) => num(b.pnl_pct) - num(a.pnl_pct));
}
}
async function pfLoad(withBroker) {
const stratEl = document.querySelector('input[name=pf_strategy]:checked');
const strat = stratEl ? stratEl.value : 'ALL';
const brokerQ = withBroker ? '&broker=1' : '';
showSpinner(true);
try {
const r = await fetch(
'/api/portfolio/active?strategy=' + encodeURIComponent(strat) + brokerQ
);
const d = await r.json();
if (!d.ok) { alert(d.error || '조회 실패'); return; }
_pfItems = d.items || [];
_pfMeta = {
kis_mock: d.kis_mock,
with_broker: d.with_broker,
broker_codes: d.broker_codes,
count: d.count,
account: d.account || null,
};
pfRenderAccountSummary(d.account, d.with_broker);
pfRenderTable();
} catch (e) {
alert('보유 조회 오류: ' + e);
} finally {
showSpinner(false);
}
}
/** 보유·매도 — 매수시각 강조 (날짜 + 시각 분리) */
function pfFmtBuyDate(s) {
if (!s) return '<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 nameDisplay = (it.name && it.name !== it.code)
? `${it.name}<br><small class="text-muted">${it.code}</small>`
: `<b>${it.code}</b>`;
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>';
const isUs = String(it.strategy || '').toUpperCase() === 'US_MOMENTUM'
|| (!/^\d{6}$/.test(String(it.code || '')));
const buyRef = Number(it.broker_avg_price > 0 ? it.broker_avg_price : it.buy_price) || 0;
const qtyRef = Number(it.broker_qty != null && it.broker_qty > 0 ? it.broker_qty : it.db_qty) || 0;
const liveAttrs = (String(it.code || '') && buyRef > 0 && qtyRef > 0)
? ` data-live-code="${String(it.code).replace(/"/g, '')}" data-live-buy="${buyRef}" data-live-qty="${qtyRef}" data-live-usd="${isUs ? '1' : '0'}"`
: '';
tbody.insertAdjacentHTML('beforeend', `
<tr${liveAttrs}>
<td>${stratLbl}</td>
<td>${nameDisplay}</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 data-live-cell="px">${fmtWon(it.current_price)}</td>
<td class="${pnlCls}" data-live-cell="day">—</td>
<td class="${pnlCls}" data-live-cell="pct">${it.pnl_pct >= 0 ? '+' : ''}${it.pnl_pct}%</td>
<td data-live-cell="eval">${fmtWon(it.eval_amt)}</td>
<td>${sellBtn}</td>
</tr>`);
});
liveQuoteStartFromDom(tbody);
}
// 보유·매도 정렬 버튼 (다른 탭과 동일 UX) — pf-sort-btn 클릭 시 재조회 없이 정렬만 갱신
document.addEventListener('click', e => {
const btn = e.target.closest('#pf_sort_bar .pf-sort-btn');
if (!btn) return;
_pfSort = btn.dataset.sort || 'pnl_desc';
pfRenderTable();
});
async function pfSell(code, strategy, name, qty) {
const msg =
`[${strategy}] ${name}(${code})\n` +
`DB 보유 ${qty}주를 시장가 전량 매도합니다.\n` +
`OrderManager 매도 경로로 DB(trade_history) 동기화됩니다.\n\n` +
`HTS 에서 이미 팔았다면 실계좌 0주일 수 있으며, 그 경우 유령잔고 정리만 됩니다.\n계속할까요?`;
if (!confirm(msg)) return;
showSpinner(true);
try {
const r = await fetch('/api/portfolio/sell', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, strategy }),
});
const d = await r.json();
if (d.ok) {
alert(
`매도 접수 완료\nODNO: ${d.ord_no || '-'}\n` +
`체결 ${d.filled_qty || '-'}주 @ ${Number(d.filled_avg_price || 0).toLocaleString()}`
);
pfLoad(false);
} else {
alert('매도 실패: ' + (d.error || d.detail || 'unknown'));
}
} catch (e) {
alert('매도 요청 오류: ' + e);
} finally {
showSpinner(false);
}
}
/** 미등록 보유분 중 '봇 고아'만 전량 시장가 일괄매도 — 수동매수/보호목록은 제외 */
async function pfSellUntrackedAll() {
if (!_pfMeta || !_pfMeta.with_broker) {
alert('먼저 「📡 실계좌 대조」를 눌러 실계좌 잔고를 불러온 뒤 실행하세요.');
return;
}
const untracked = (_pfItems || []).filter(x => x.untracked && (x.broker_qty || 0) > 0);
// 봇 고아(origin==='bot')만 매도 대상, 수동(origin==='manual')은 보호
const targets = untracked.filter(x => x.origin === 'bot');
const manual = untracked.filter(x => x.origin !== 'bot');
if (!targets.length) {
alert(
'매도 대상(봇 고아) 종목이 없습니다.' +
(manual.length ? `\n수동매수 추정 ${manual.length}종목은 보호되어 제외됩니다.` : '')
);
return;
}
const totalEval = targets.reduce((s, x) => s + (Number(x.eval_amt) || 0), 0);
const preview = targets.slice(0, 15)
.map(x => ` · ${x.name}(${x.code}) ${x.broker_qty}`).join('\n');
const more = targets.length > 15 ? `\n …외 ${targets.length - 15}종목` : '';
const manualNote = manual.length
? `\n\n🛡️ 보호(제외) ${manual.length}종목: 수동매수 추정/보호목록\n` +
manual.slice(0, 10).map(x => ` · ${x.name}(${x.code})`).join('\n')
: '';
const msg =
`⚠️ 봇 고아(미기록 체결) ${targets.length}종목을 시장가로 전량 매도합니다.\n` +
`예상 평가금 합계: 약 ${Number(totalEval).toLocaleString()}\n\n` +
`${preview}${more}${manualNote}\n\n` +
`※ 봇 관리분(active_trades)·수동매수분은 건드리지 않습니다.\n` +
`※ 시장가 주문이라 슬리피지가 발생할 수 있습니다.\n계속할까요?`;
if (!confirm(msg)) return;
showSpinner(true);
try {
const r = await fetch('/api/portfolio/sell_untracked_all', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const d = await r.json();
if (d.ok) {
let out = `일괄매도 접수: 성공 ${d.sold_count || 0}건 / 실패 ${d.failed_count || 0}`
+ (d.protected_count ? ` / 보호 ${d.protected_count}` : '');
if ((d.failed || []).length) {
out += '\n\n[실패]\n' + d.failed.slice(0, 10)
.map(f => ` · ${f.name}(${f.code}): ${f.error}`).join('\n');
}
if (d.msg) out = d.msg;
alert(out);
pfLoad(true); // 실계좌 재대조
} else {
alert('일괄매도 실패: ' + (d.error || 'unknown'));
}
} catch (e) {
alert('일괄매도 요청 오류: ' + e);
} finally {
showSpinner(false);
}
}
/** 봇 고아(active_trades 미기록) 수동 DB복구 — 매도 아님. 장마감 배치와 동일 로직 */
async function pfReconcileOrphans() {
const msg =
`봇이 매수했지만 active_trades 에 누락된 종목을 DB로 복구합니다.\n` +
`(매도가 아니라 봇이 관리하도록 등록만 — 이후 손절/익절 신호가 작동)\n\n` +
`※ 수동매수(주문기록 없음)·MANUAL_HOLD_CODES 종목은 제외됩니다.\n계속할까요?`;
if (!confirm(msg)) return;
showSpinner(true);
try {
const r = await fetch('/api/portfolio/reconcile_orphans', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const d = await r.json();
if (d.ok) {
let out = `봇고아 DB복구: ${d.reconciled_count || 0}종목 복구`
+ (d.failed_count ? ` / 실패 ${d.failed_count}` : '')
+ `\n(제외: 수동 ${d.skipped_manual_count || 0} · 이미추적 ${d.skipped_tracked_count || 0} · 주문없음 ${d.skipped_no_order_count || 0})`;
if ((d.reconciled || []).length) {
out += '\n\n[복구]\n' + d.reconciled.slice(0, 12)
.map(x => ` · ${x.name}(${x.code}) [${x.strategy}] ${x.qty}`).join('\n');
}
if ((d.failed || []).length) {
out += '\n\n[실패]\n' + d.failed.slice(0, 8)
.map(f => ` · ${f.name}(${f.code}): ${f.error}`).join('\n');
}
alert(out);
pfLoad(true); // 실계좌 재대조 (복구분이 봇추적으로 전환됨)
} else {
alert('고아복구 실패: ' + (d.error || 'unknown'));
}
} catch (e) {
alert('고아복구 요청 오류: ' + e);
} finally {
showSpinner(false);
}
}
// ────────────────────────────────────────────
// 날짜 기본값 설정 (KST = 한국장 기준, 브라우저 타임존과 무관)
// ────────────────────────────────────────────
function fmtKstDate(d) {
return new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Seoul' }).format(d);
}
function kstTodayParts() {
const iso = fmtKstDate(new Date());
const [y, m, day] = iso.split('-').map(x => parseInt(x, 10));
return { y, m, day, iso };
}
function kstMonthStartIso() {
const t = kstTodayParts();
return `${t.y}-${String(t.m).padStart(2, '0')}-01`;
}
function kstMonthAgoIso() {
const t = kstTodayParts();
let idx = t.y * 12 + (t.m - 1) - 1;
const ny = Math.floor(idx / 12);
const nm = (idx % 12) + 1;
const lastDay = new Date(ny, nm, 0).getDate();
const nd = Math.min(t.day, lastDay);
return `${ny}-${String(nm).padStart(2, '0')}-${String(nd).padStart(2, '0')}`;
}
/** 현재 KST 시(0~23) */
function kstHourNow() {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: 'Asia/Seoul', hour: 'numeric', hour12: false,
}).formatToParts(new Date());
const h = parts.find(p => p.type === 'hour');
return h ? (parseInt(h.value, 10) % 24) : 12;
}
function kstYesterdayIso() {
return fmtKstDate(new Date(Date.now() - 24 * 3600 * 1000));
}
/** N일 전 KST 날짜 (백테 기본 시작일 등) */
function kstDaysAgoIso(n) {
return fmtKstDate(new Date(Date.now() - n * 24 * 3600 * 1000));
}
function setKrHolidays(list) {
_krHolidays = new Set((list || []).map(String).filter(Boolean));
}
/** iso 하루 전 (캘린더) */
function kstAddDaysIso(iso, deltaDays) {
const [y, m, d] = String(iso).split('-').map(x => parseInt(x, 10));
const dt = new Date(Date.UTC(y, m - 1, d));
dt.setUTCDate(dt.getUTCDate() + deltaDays);
const yy = dt.getUTCFullYear();
const mm = String(dt.getUTCMonth() + 1).padStart(2, '0');
const dd = String(dt.getUTCDate()).padStart(2, '0');
return `${yy}-${mm}-${dd}`;
}
function kstWeekdayMon0(iso) {
const [y, m, d] = String(iso).split('-').map(x => parseInt(x, 10));
// UTC 정오로 파싱해 요일 드리프트 방지
return new Date(Date.UTC(y, m - 1, d, 12, 0, 0)).getUTCDay(); // 0=일 … 6=토
}
function kstIsTradingDayIso(iso, holidays) {
if (!iso) return false;
const wd = kstWeekdayMon0(iso);
if (wd === 0 || wd === 6) return false;
const hol = holidays || _krHolidays;
return !(hol && hol.has(iso));
}
/** 주말·휴장이면 이전 장운영일(YYYY-MM-DD) */
function kstClampToPrevTradingDayIso(iso, holidays, maxBack) {
let cur = String(iso || '').slice(0, 10);
if (!cur) return cur;
const lim = Math.max(1, maxBack || 14);
for (let i = 0; i <= lim; i++) {
if (kstIsTradingDayIso(cur, holidays)) return cur;
cur = kstAddDaysIso(cur, -1);
}
return cur;
}
/** 실거래 조회 기준일 — 장 시작 전이면 전날, 이후 주말/휴장 보정 */
function kstTradingDayIso(holidays) {
const base = kstHourNow() < 9 ? kstYesterdayIso() : kstTodayParts().iso;
return kstClampToPrevTradingDayIso(base, holidays);
}
function setDateVal(id, val) {
const el = $(id);
if (el && val) el.value = val;
}
/** 날짜 인풋: 주말/휴장 선택 시 이전 장운영일로 스냅 */
function bindKrTradingDayDateInputs() {
document.querySelectorAll('input[type="date"]').forEach(el => {
if (el.dataset.krTradingBound === '1') return;
el.dataset.krTradingBound = '1';
el.addEventListener('change', function() {
if (!this.value) return;
const c = kstClampToPrevTradingDayIso(this.value, _krHolidays);
if (c && c !== this.value) this.value = c;
});
});
}
function initDefaultDates(datesPayload) {
const hol = (datesPayload && datesPayload.holidays) ? datesPayload.holidays : [..._krHolidays];
setKrHolidays(hol);
const endKst = (datesPayload && datesPayload.end)
? datesPayload.end
: kstClampToPrevTradingDayIso(kstTodayParts().iso, _krHolidays);
const startKst = (datesPayload && datesPayload.start)
? datesPayload.start
: kstClampToPrevTradingDayIso(kstAddDaysIso(endKst, -7), _krHolidays);
// 실거래 분석: 단일 거래일 기본 조회 (장전·주말·휴장 → 이전 장운영일)
const tradingDayKst = (datesPayload && datesPayload.trading_day)
? datesPayload.trading_day
: kstTradingDayIso(_krHolidays);
setDateVal('act_start', tradingDayKst);
setDateVal('act_end', tradingDayKst);
setDateVal('dash_date', tradingDayKst);
setDateVal('lc_date', tradingDayKst);
// 백테 탭: 시작 ~ 종료 (둘 다 거래일)
setDateVal('bt_start', startKst);
setDateVal('bt_end', endKst);
setDateVal('tl_start', startKst);
setDateVal('tl_end', endKst);
setDateVal('db_start', startKst);
setDateVal('db_end', endKst);
setDateVal('db_fetch_start', startKst);
setDateVal('bo_start', startKst);
setDateVal('bo_end', endKst);
setDateVal('rb_start', startKst);
setDateVal('rb_end', endKst);
setDateVal('mom_start', startKst);
setDateVal('mom_end', endKst);
setDateVal('usmom_start', startKst);
setDateVal('usmom_end', endKst);
setDateVal('hd_start', tradingDayKst);
setDateVal('hd_end', tradingDayKst);
setDateVal('hd_fetch_start', tradingDayKst);
setDateVal('ubx_start', startKst);
setDateVal('ubx_end', endKst);
const lr = $('lastRefresh');
if (lr) lr.textContent = '업데이트: ' + new Date().toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' });
bindKrTradingDayDateInputs();
}
(function() {
// 주말만이라도 즉시 보정 → /api/env/params 의 dates 로 휴장일 재보정
initDefaultDates(null);
const hdFetch = $('hd_fetch_start');
if (hdFetch) {
hdFetch.addEventListener('change', function() {
if (this.value) setDateVal('hd_start', this.value);
});
}
// ── DB config_scalp / config_momentum / config_short → 스캘핑·꼬리·모멘텀 초기값 ──
// DB 값이 있으면 반영, null이면 HTML에 하드코딩된 기본값 그대로 유지
fetch('/api/env/params')
.then(r => r.json())
.then(d => {
if (d && d.dates) initDefaultDates(d.dates);
const set = (id, val) => { if (val !== null && val !== undefined) $(id).value = val; };
const setM = set; // 돌파 탭 필드 (setM 미정의 시 ReferenceError → catch로 HTML 기본값 21 고정됨)
// 스캘핑 탭
const s = d.scalp || {};
set('bt_rsi_oversold', s.rsi_oversold);
set('bt_rsi_overbought', s.rsi_overbought);
set('bt_sl', s.sl_pct);
set('bt_tp', s.tp_pct);
set('bt_tp_max', s.tp_max_pct);
set('bt_drop', s.drop_rate);
set('bt_smin', s.shoulder_min_high);
set('bt_scut', s.shoulder_cut_pct);
set('bt_cooldown', s.cooldown_min);
if (s.slot_money) set('bt_slot', s.slot_money);
if (s.high_chase_thr != null) set('bt_high_chase', s.high_chase_thr);
if (s.max_daily_chg != null) set('bt_max_daily_chg', s.max_daily_chg);
if (s.min_price != null) set('bt_min_price', s.min_price);
if (s.max_loss_krw != null) set('bt_max_loss_krw', s.max_loss_krw);
if (s.min_margin != null) set('bt_min_margin', s.min_margin);
if (s.use_defense_filters !== undefined && $('bt_use_defense')) {
const v = String(s.use_defense_filters).trim().toLowerCase();
$('bt_use_defense').checked = (v === '1' || v === 'true' || v === 'y' || v === 'yes' || v === 'on');
}
if (s.use_macd_cross !== undefined && $('bt_use_macd')) {
const v = String(s.use_macd_cross).trim().toLowerCase();
$('bt_use_macd').checked = (v === '1' || v === 'true' || v === 'y' || v === 'yes' || v === 'on');
}
set('bt_rsi_period', s.rsi_period);
set('bt_vol_mult', s.vol_mult);
set('bt_time_start', s.time_start_hm);
set('bt_time_end', s.time_end_hm);
set('bt_max_daily', s.max_daily);
if (s.max_stocks != null) set('bt_max_stocks', s.max_stocks);
if (s.total_budget_krw != null) set('bt_total_budget', s.total_budget_krw);
if ($('bt_skip_hts_dupes')) $('bt_skip_hts_dupes').checked = s.skip_hts_scan_dupes !== false;
if ($('bt_require_reversal')) {
const rv = s.require_reversal_candle;
$('bt_require_reversal').checked = rv !== false && rv !== 0 && rv !== '0' && rv !== 'false';
}
if (s.eod_enabled !== undefined && $('bt_eod_enabled')) {
const ev = String(s.eod_enabled).trim().toLowerCase();
$('bt_eod_enabled').checked = (ev === '1' || ev === 'true' || ev === 'y' || ev === 'yes' || ev === 'on' || s.eod_enabled === true);
}
if (s.eod_hm != null) set('bt_eod_hm', s.eod_hm);
// 꼬리잡기 탭 — config_short + env_config (실매·파라서치와 동일)
fillTailFormFromApi(d.tail || {});
// 더블BB 탭
fillDbBandFormFromApi(d.dbband || {});
// 모멘텀 탭 — config_momentum + env_config
fillMomentumFormFromApi(d.momentum || {});
fillUsMomentumFormFromApi(d.us_momentum || d.momentum || {});
// 돌파 탭 — DB(env_config BREAKOUT_*) = 백테 API _bo_defaults_from_db 와 동일
const b = d.breakout || {};
setM('bo_lookback', b.lookback_min);
setM('bo_vol_win', b.vol_window);
setM('bo_vol_mult', b.vol_mult);
setM('bo_min_turnover', b.min_turnover_1m_pct);
setM('bo_prev_min', b.prev_chg_min);
setM('bo_prev_max', b.prev_chg_max);
if (b.entry_mode && $('bo_entry_mode')) {
$('bo_entry_mode').value = String(b.entry_mode).toLowerCase();
}
setM('bo_intrabar_slip', b.intrabar_slippage_pct);
if (b.sl_mode && $('bo_sl_mode')) {
$('bo_sl_mode').value = String(b.sl_mode).toLowerCase();
}
setM('bo_sl', b.sl_pct);
if (b.atr_period != null) setM('bo_atr_period', b.atr_period);
if (b.atr_sl_mult != null) setM('bo_atr_sl_mult', b.atr_sl_mult);
if (b.atr_sl_min_pct != null) setM('bo_atr_sl_min', b.atr_sl_min_pct);
if (b.atr_sl_max_pct != null) setM('bo_atr_sl_max', b.atr_sl_max_pct);
boSyncSlModeColors();
setM('bo_tp', b.tp_pct);
setM('bo_trail', b.trail_pct);
if (b.trail_arm_pct != null) setM('bo_trail_arm', b.trail_arm_pct);
setM('bo_shoulder_smin', b.shoulder_min_high_pct);
setM('bo_shoulder_scut', b.shoulder_cut_pct);
setM('bo_time_start', b.time_start_hm);
setM('bo_time_end', b.time_end_hm);
if (b.eod_enabled !== undefined && $('bo_eod_enabled')) {
$('bo_eod_enabled').checked = !!b.eod_enabled;
}
if (b.eod_hm != null) setM('bo_eod_hm', b.eod_hm);
if (b.max_hold_bars != null) setM('bo_max_hold', b.max_hold_bars);
setM('bo_max_daily', b.max_daily);
setM('bo_cooldown', b.cooldown_min);
setM('bo_max_chg', b.max_daily_chg);
setM('bo_min_price', b.min_price);
if (b.confirm_margin_pct != null) setM('bo_confirm_margin', b.confirm_margin_pct);
if (b.body_min_pct != null) setM('bo_body_min', b.body_min_pct);
if ($('bo_ratchet')) $('bo_ratchet').value = (b.ratchet_tiers != null ? String(b.ratchet_tiers) : '');
setM('bo_max_loss', b.max_loss_krw);
if (b.use_ema_filter !== undefined && $('bo_use_ema_f')) {
$('bo_use_ema_f').checked = !!b.use_ema_filter;
}
if (b.ema_fast_period != null) setM('bo_ema_fast', b.ema_fast_period);
if (b.ema_slow_period != null) setM('bo_ema_slow', b.ema_slow_period);
if ($('bo_skip_hts_dupes')) $('bo_skip_hts_dupes').checked = !!b.skip_hts_scan_dupes;
if (b.ob_filter_enabled !== undefined && $('bo_ob_filter')) {
$('bo_ob_filter').checked = !!b.ob_filter_enabled;
}
if (b.max_spread_pct != null && $('bo_max_spread_pct')) {
$('bo_max_spread_pct').value = b.max_spread_pct;
}
if (b.pg_filter_enabled !== undefined && $('bo_pg_filter')) {
$('bo_pg_filter').checked = !!b.pg_filter_enabled;
}
setM('bo_slot', b.slot_money);
if (b.max_stocks != null) setM('bo_max_stocks', b.max_stocks);
if (b.total_budget_krw != null) setM('bo_total_budget', b.total_budget_krw);
// 당일 누적손익 다단 트레일(꼬리와 동일) 현재값·방식·프리셋 복원
if (b.daily_profit_enabled !== undefined && $('bo_daily_profit_enabled')) {
$('bo_daily_profit_enabled').checked = !!b.daily_profit_enabled;
}
if (b.daily_risk_budget_enabled !== undefined && $('bo_daily_risk_budget_enabled')) {
$('bo_daily_risk_budget_enabled').checked = !!b.daily_risk_budget_enabled;
}
if ($('bo_daily_trail_tiers')) {
$('bo_daily_trail_tiers').value = (b.daily_trail_tiers != null ? String(b.daily_trail_tiers) : '');
}
if (b.daily_profit_mode && $('bo_daily_profit_mode')) {
$('bo_daily_profit_mode').value = b.daily_profit_mode;
}
btFillPresetOptions('bo_daily_trail_preset', b.daily_trail_presets);
const rb = d.range_break || {};
setM('rb_box_lb', rb.box_lookback_min);
setM('rb_box_max_w', rb.box_max_width_pct);
setM('rb_setup_vol', rb.setup_vol_max_mult);
setM('rb_bear_min', rb.setup_bear_bars_min);
setM('rb_vol_win', rb.vol_window);
setM('rb_vol_mult', rb.vol_mult);
setM('rb_sl', rb.sl_pct);
setM('rb_tp', rb.tp_pct);
setM('rb_trail', rb.trail_pct);
if (rb.trail_arm_pct != null) setM('rb_trail_arm', rb.trail_arm_pct);
setM('rb_shoulder_smin', rb.shoulder_min_high_pct);
setM('rb_shoulder_scut', rb.shoulder_cut_pct);
setM('rb_time_start', rb.time_start_hm);
setM('rb_time_end', rb.time_end_hm);
setM('rb_max_daily', rb.max_daily);
setM('rb_cooldown', rb.cooldown_min);
setM('rb_max_chg', rb.max_daily_chg);
setM('rb_min_price', rb.min_price);
setM('rb_max_loss', rb.max_loss_krw);
if (rb.use_high_chase_filter !== undefined && $('rb_use_high_chase')) {
$('rb_use_high_chase').checked = !!rb.use_high_chase_filter;
}
setM('rb_slot', rb.slot_money);
if (rb.max_stocks != null) setM('rb_max_stocks', rb.max_stocks);
if (rb.total_budget_krw != null) setM('rb_total_budget', rb.total_budget_krw);
// 날짜(거래일) 확정 후 실거래 분석 1회 — 빈 날짜로 전체 조회·고스트 노출 방지
loadActual();
})
.catch(() => {
/* DB 연결 실패 시 HTML 기본값 유지 — 로컬 거래일로라도 조회 */
loadActual();
});
const defChk = $('bt_use_defense');
if (defChk) {
defChk.addEventListener('change', saveScalpDefenseRealtime);
}
const macdChk = $('bt_use_macd');
if (macdChk) {
macdChk.addEventListener('change', saveScalpMacdRealtime);
}
})();
// ────────────────────────────────────────────
// 실거래 분석 로드
// ────────────────────────────────────────────
function loadActual() {
const strategy = document.querySelector('input[name=act_strategy]:checked').value;
const start = $('act_start').value;
const end = $('act_end').value;
showSpinner(true);
fetch(`/api/actual?strategy=${encodeURIComponent(strategy)}&start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}`)
.then(r => r.json())
.then(d => {
showSpinner(false);
renderActual(d);
})
.catch(err => { showSpinner(false); alert('오류: ' + err); });
}
function renderActual(d) {
const s = d.summary;
const p = d.params || {};
const filtered = Number((d.meta || {}).filtered_forced_rows || 0);
const isUs = String(d.currency || p.market || '').toUpperCase() === 'USD'
|| String(document.querySelector('input[name=act_strategy]:checked')?.value || '').toUpperCase() === 'US_MOMENTUM';
const fxEnd = Number(s.fx_rate_end || 0) || getDisplayFxRate();
if (fxEnd > 0) setDisplayFxRate(fxEnd);
$('a_total').textContent = fmt(s.total_trades) + '건';
$('a_winrate').textContent = s.win_rate + '%';
colorPnl($('a_winrate'), s.win_rate - 50);
if (isUs) {
if (s.total_pnl_krw != null && Number.isFinite(Number(s.total_pnl_krw))) {
$('a_pnl').innerHTML = `${fmtUsd(s.total_pnl)} <span style="color:var(--muted);font-size:12px">(${fmtWon(s.total_pnl_krw)}원)</span>`;
} else {
$('a_pnl').innerHTML = fmtUsdWithKrw(s.total_pnl, fxEnd);
}
colorPnl($('a_pnl'), s.total_pnl);
if (s.max_drawdown_krw != null && Number.isFinite(Number(s.max_drawdown_krw))) {
$('a_mdd').innerHTML = `-${fmtUsd(s.max_drawdown)} <span style="color:var(--muted);font-size:12px">(${fmtWon(s.max_drawdown_krw)}원)</span>`;
} else {
$('a_mdd').textContent = '-' + fmtUsdWithKrwText(s.max_drawdown, fxEnd);
}
} else {
$('a_pnl').textContent = fmtKrw(s.total_pnl);
colorPnl($('a_pnl'), s.total_pnl);
$('a_mdd').textContent = '-' + fmtWon(s.max_drawdown) + '원';
}
$('a_pf').textContent = s.profit_factor >= 999 ? '∞' : s.profit_factor;
colorPnl($('a_pf'), s.profit_factor - 1);
$('a_hold').textContent = s.avg_hold_min + '분';
// 운용한도 대비 수익률 — API summary에 없으면 params.total_budget_krw 로 계산
const aSign = (v) => (v > 0 ? '+' : '');
let botPct = s.bot_pct;
let dailyAvg = s.daily_avg_pct;
const tb = Number(p.total_budget_krw || 0);
if ((botPct == null || botPct === '') && tb > 0) {
botPct = Math.round((Number(s.total_pnl || 0) / tb) * 10000) / 100;
}
if ((dailyAvg == null || dailyAvg === '') && botPct != null) {
const startEl = $('act_start');
const endEl = $('act_end');
let days = 1;
try {
if (startEl && endEl && startEl.value && endEl.value) {
const t0 = new Date(startEl.value + 'T00:00:00');
const t1 = new Date(endEl.value + 'T00:00:00');
days = Math.max(1, Math.round((t1 - t0) / 86400000) + 1);
}
} catch (e) { days = 1; }
dailyAvg = Math.round((Number(botPct) / days) * 1000) / 1000;
}
if ($('a_bot_pct')) {
$('a_bot_pct').textContent = (botPct == null) ? '-' : (aSign(botPct) + botPct + '%');
if (botPct != null) colorPnl($('a_bot_pct'), botPct);
}
if ($('a_daily_avg_pct')) {
$('a_daily_avg_pct').textContent = (dailyAvg == null) ? '-' : (aSign(dailyAvg) + dailyAvg + '%');
if (dailyAvg != null) colorPnl($('a_daily_avg_pct'), dailyAvg);
}
const openCnt = Number((d.meta || {}).open_count || 0);
const closedCnt = Number((d.meta || {}).closed_count || 0);
const hint = $('act_table_hint');
if (hint) {
hint.textContent = `(청산 ${closedCnt}건 · 보유 ${openCnt}건 — 상단 요약·차트는 청산만)`;
}
const actCtx = $('act_trade_context');
if (actCtx) {
fillTradePnLContext(actCtx, {
label: '실매 trade_history',
totalBudget: Number(p.total_budget_krw || 0),
peakCum: Number(s.peak_cum_pnl || 0),
peakAt: s.peak_cum_at || '',
totalPnl: s.total_pnl,
});
}
const note = $('act_filter_note');
if (note) {
const parts = [];
if (openCnt > 0) {
parts.push(`📌 보유중 <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(`[실거래 분석] ghost_purge·강제정리 ${filtered}건 제외 후 집계`);
}
// 승/패 바
if (s.total_trades > 0) {
$('act_winbar_card').style.display = '';
const wr = s.win_rate;
$('a_win_label').textContent = `🟢 승 ${s.win_trades}건 (${wr}%)`;
$('a_loss_label').textContent = `🔴 패 ${s.loss_trades}건 (${(100-wr).toFixed(1)}%)`;
$('a_ratio_g').style.width = wr + '%';
$('a_ratio_r').style.width = (100 - wr) + '%';
}
// 누적 손익 곡선
const eqLabels = d.equity.map((e,i) => i % Math.max(1, Math.floor(d.equity.length/20)) === 0 ? e.date : '');
lineChart('a_equity_chart', d.equity.map(e => e.date), d.equity.map(e => e.cum_pnl), '누적손익');
// 매도이유 도넛
const rKeys = Object.keys(d.reasons);
doughnutChart('a_reason_chart', rKeys, rKeys.map(k => d.reasons[k]));
// 종목별 손익 바
barChart('a_code_chart',
d.top_codes.map(c => c.name || c.code),
d.top_codes.map(c => c.pnl));
// 일별 바
barChart('a_daily_chart',
d.daily.map(e => e.date.slice(5)),
d.daily.map(e => e.pnl));
renderVirtualTrades('act_tbody', d.trades || [], {
showDebug: true,
showCumulative: true,
totalBudget: Number(p.total_budget_krw || 0),
currency: isUs ? 'USD' : '',
fxRates: d.fx_rates || {},
fxRate: fxEnd,
});
}
// ────────────────────────────────────────────
// 돌파 백테스트 실행
// ────────────────────────────────────────────
function runBreakoutBacktest() {
let start = $('bo_start')?.value || '';
let end = $('bo_end')?.value || '';
if (!start) {
start = kstMonthStartIso();
setDateVal('bo_start', start);
}
if (!end) {
end = kstTodayParts().iso;
setDateVal('bo_end', end);
}
if (end && start && end < start) {
alert('종료일이 시작일보다 앞섭니다. 날짜를 확인하세요.');
return;
}
const params = {
start,
end,
lookback_min: $('bo_lookback').value,
vol_window: $('bo_vol_win').value,
vol_mult: $('bo_vol_mult').value,
min_turnover_1m_pct: $('bo_min_turnover')?.value || '0.05',
prev_chg_min: $('bo_prev_min').value,
prev_chg_max: $('bo_prev_max').value,
entry_mode: $('bo_entry_mode')?.value || 'intrabar',
intrabar_slippage_pct: $('bo_intrabar_slip')?.value || '0',
sl_mode: ($('bo_sl_mode') && $('bo_sl_mode').value) || 'fixed',
sl_pct: $('bo_sl').value,
atr_period: $('bo_atr_period')?.value || '14',
atr_sl_mult: $('bo_atr_sl_mult')?.value || '2.0',
atr_sl_min_pct: $('bo_atr_sl_min')?.value || '0.8',
atr_sl_max_pct: $('bo_atr_sl_max')?.value || '6.0',
tp_pct: $('bo_tp').value,
trail_pct: $('bo_trail').value,
trail_arm_pct: $('bo_trail_arm')?.value || '0',
shoulder_min_high_pct: $('bo_shoulder_smin').value,
shoulder_cut_pct: $('bo_shoulder_scut').value,
time_start_hm: $('bo_time_start').value,
time_end_hm: $('bo_time_end').value,
max_daily: $('bo_max_daily').value,
cooldown_min: $('bo_cooldown').value,
max_daily_chg: $('bo_max_chg').value,
min_price: $('bo_min_price').value,
confirm_margin_pct: $('bo_confirm_margin')?.value || '0',
body_min_pct: $('bo_body_min')?.value || '0',
ratchet_tiers: ($('bo_ratchet') && $('bo_ratchet').value.trim()) || '',
use_ema_filter: $('bo_use_ema_f')?.checked ? 1 : 0,
ema_fast_period: $('bo_ema_fast')?.value || '9',
ema_slow_period: $('bo_ema_slow')?.value || '21',
max_loss_krw: $('bo_max_loss').value,
slot_money: $('bo_slot').value,
max_stocks: $('bo_max_stocks')?.value || '20',
total_budget_krw: $('bo_total_budget')?.value || '0',
universe: $('bo_use_univ_history')?.checked ? 'history' : 'all',
universe_history_source: univHistorySourceParam('bo_univ_history_source'),
candle_source: $('bo_candle_source')?.value || '',
tick_source: $('bo_tick_source')?.value || '',
ob_source: $('bo_ob_source')?.value || '',
ob_filter: $('bo_ob_filter')?.checked ? 1 : 0,
pg_filter: $('bo_pg_filter')?.checked ? 1 : 0,
max_spread_pct: $('bo_max_spread_pct')?.value,
daily_trail_tiers: ($('bo_daily_trail_tiers') && $('bo_daily_trail_tiers').value.trim()) || '',
daily_trail_drop_pct: $('bo_daily_trail_drop')?.value || '0',
daily_trail_arm_krw: $('bo_daily_trail_arm')?.value || '0',
daily_profit_mode: ($('bo_daily_profit_mode') && $('bo_daily_profit_mode').value) || 'trailing',
daily_profit_enabled: $('bo_daily_profit_enabled')?.checked ? 1 : 0,
daily_risk_budget_enabled: $('bo_daily_risk_budget_enabled')?.checked ? 1 : 0,
eod_enabled: $('bo_eod_enabled')?.checked ? 1 : 0,
eod_hm: ($('bo_eod_hm') && $('bo_eod_hm').value.trim()) || '15:15',
skip_hts_scan_dupes: $('bo_skip_hts_dupes')?.checked ? 1 : 0,
env_timeline: envTimelineParam('bo_env_timeline'),
};
const qs = new URLSearchParams(params).toString();
showSpinner(true);
fetch('/api/backtest/breakout?' + qs)
.then(async r => {
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.error || ('HTTP ' + r.status));
return d;
})
.then(d => { showSpinner(false); renderBreakoutBacktest(d); })
.catch(err => { showSpinner(false); alert('백테스트 오류: ' + err); });
}
function saveBreakoutConfig() {
const body = {
lookback_min: parseInt($('bo_lookback').value, 10),
vol_window: parseInt($('bo_vol_win').value, 10),
vol_mult: parseFloat($('bo_vol_mult').value),
min_turnover_1m_pct: parseFloat($('bo_min_turnover')?.value || '0.05'),
prev_chg_min: parseFloat($('bo_prev_min').value),
prev_chg_max: parseFloat($('bo_prev_max').value),
entry_mode: $('bo_entry_mode')?.value || 'intrabar',
intrabar_slippage_pct: parseFloat($('bo_intrabar_slip')?.value || '0'),
sl_mode: ($('bo_sl_mode') && $('bo_sl_mode').value) || 'fixed',
sl_pct: parseFloat($('bo_sl').value),
atr_period: parseInt($('bo_atr_period')?.value || '14', 10),
atr_sl_mult: parseFloat($('bo_atr_sl_mult')?.value || '2.0'),
atr_sl_min_pct: parseFloat($('bo_atr_sl_min')?.value || '0.8'),
atr_sl_max_pct: parseFloat($('bo_atr_sl_max')?.value || '6.0'),
tp_pct: parseFloat($('bo_tp').value),
trail_pct: parseFloat($('bo_trail').value),
trail_arm_pct: parseFloat($('bo_trail_arm')?.value || '0'),
shoulder_min_high_pct: parseFloat($('bo_shoulder_smin').value),
shoulder_cut_pct: parseFloat($('bo_shoulder_scut').value),
time_start_hm: parseInt($('bo_time_start').value, 10),
time_end_hm: parseInt($('bo_time_end').value, 10),
eod_enabled: !!($('bo_eod_enabled')?.checked),
eod_hm: ($('bo_eod_hm') && $('bo_eod_hm').value.trim()) || '15:15',
max_hold_bars: parseInt($('bo_max_hold')?.value || '0', 10),
max_daily: parseInt($('bo_max_daily').value, 10),
cooldown_min: parseFloat($('bo_cooldown').value),
max_daily_chg: parseFloat($('bo_max_chg').value),
min_price: parseFloat($('bo_min_price').value),
confirm_margin_pct: parseFloat($('bo_confirm_margin')?.value || '0'),
body_min_pct: parseFloat($('bo_body_min')?.value || '0'),
ratchet_tiers: ($('bo_ratchet') && $('bo_ratchet').value.trim()) || '',
use_ema_filter: !!($('bo_use_ema_f')?.checked),
ema_fast_period: parseInt($('bo_ema_fast')?.value || '9', 10),
ema_slow_period: parseInt($('bo_ema_slow')?.value || '21', 10),
max_loss_krw: parseInt($('bo_max_loss').value, 10),
slot_money: parseInt($('bo_slot').value, 10),
max_stocks: parseInt($('bo_max_stocks')?.value || '20', 10),
total_budget_krw: parseInt($('bo_total_budget')?.value || '0', 10),
ob_filter: !!($('bo_ob_filter')?.checked),
pg_filter: !!($('bo_pg_filter')?.checked),
daily_trail_tiers: ($('bo_daily_trail_tiers') && $('bo_daily_trail_tiers').value.trim()) || '',
daily_profit_mode: ($('bo_daily_profit_mode') && $('bo_daily_profit_mode').value) || 'trailing',
daily_profit_enabled: !!($('bo_daily_profit_enabled')?.checked),
daily_risk_budget_enabled: !!($('bo_daily_risk_budget_enabled')?.checked),
skip_hts_scan_dupes: !!($('bo_skip_hts_dupes')?.checked),
};
if (!confirm('💾 돌파 봇(BREAKOUT_* env)에 현재 탭 값을 저장할까요?\n실행 중이면 다음 루프부터 반영됩니다.\n다단트레일: ' + (body.daily_profit_enabled ? 'ON' : 'OFF') + ' · 리스크버짓(B): ' + (body.daily_risk_budget_enabled ? 'ON' : 'OFF'))) return;
fetch('/api/backtest/breakout/save_config', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body),
}).then(r => r.json()).then(d => {
if (d.error) { alert('❌ ' + d.error); return; }
btAddPresetOption('bo_daily_trail_preset', $('bo_daily_trail_tiers') && $('bo_daily_trail_tiers').value);
const byTbl = d.saved_by_table || {};
const tblLines = Object.entries(byTbl).map(([t, ks]) => ` ${t}: ${ks.join(', ')}`).join('\n');
alert(`✅ 저장 완료 (env_id: ${d.env_id})\n테이블:\n${tblLines || d.saved_keys?.join(', ')}`);
}).catch(e => alert('오류: ' + e));
}
function renderBreakoutBacktest(d) {
if (!d || d.error) {
alert('❌ ' + (d && d.error ? d.error : '백테스트 응답 없음'));
return;
}
if (!d.summary) { alert('백테스트 응답 오류 (summary 없음)'); return; }
const s = d.summary;
const p = d.params || {};
const boRa = $('bo_result_area');
if (boRa) boRa.style.display = 'block';
const boPb = $('bo_params_bar');
if (boPb) boPb.style.display = 'block';
const universeLabel = formatUniverseLabel(p);
const tr = (v) => (typeof v === 'number' ? Number(v).toFixed(1) : (v != null ? v : '—'));
const periodHint = (p.start && p.end) ? ` | ${p.start}~${p.end}` : '';
const summaryLine =
`<span class="badge ${isHistoryUniverseSource(p.universe_source) ? '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'),
e_min_chg_pct: v('mom_e_min_chg') || '0.2',
mom_time_end: v('mom_time_end_buy'),
mom_max_from_open_pct: v('mom_max_from_open') || '999',
mom_min_from_open_pct: v('mom_min_from_open') || '-999',
universe: $('mom_use_univ_history')?.checked ? 'history' : 'sim',
universe_history_source: univHistorySourceParam('mom_univ_history_source'),
candle_source: $('mom_candle_source')?.value || '',
tick_source: $('mom_tick_source')?.value || '',
ob_source: $('mom_ob_source')?.value || '',
daily_trail_tiers: (v('mom_daily_trail_tiers') || '').trim(),
daily_trail_drop_pct: v('mom_daily_trail_drop') || '0',
daily_trail_arm_krw: v('mom_daily_trail_arm') || '0',
daily_profit_mode: ($('mom_daily_profit_mode') && $('mom_daily_profit_mode').value) || 'trailing',
daily_profit_enabled: $('mom_daily_profit_enabled')?.checked ? 1 : 0,
daily_risk_budget_enabled: $('mom_daily_risk_budget_enabled')?.checked ? 1 : 0,
eod_enabled: $('mom_eod_enabled')?.checked ? 1 : 0,
eod_hm: ($('mom_eod_hm') && $('mom_eod_hm').value.trim()) || '15:25',
backtest_skip_pre_subscribe: $('mom_skip_pre_sub')?.checked ? 1 : 0,
env_timeline: envTimelineParam('mom_env_timeline'),
};
const qs = new URLSearchParams(params).toString();
fetch('/api/backtest/momentum?' + qs)
.then(async r => {
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.error || ('HTTP ' + r.status));
return d;
})
.then(d => { showSpinner(false); renderMomentumBacktest(d); })
.catch(err => {
showSpinner(false);
alert('백테스트 오류: ' + err + '\n\n※ 저장 후보 이력 체크 시 1~3분 걸릴 수 있습니다. 기간을 줄이거나 체크 해제 후 재시도하세요.');
});
} catch (err) {
showSpinner(false);
alert('백테스트 실행 오류: ' + err);
}
}
/** 해외 모멘텀 백테 — /api/backtest/us_momentum (국내 momentum API 분기 사용 안 함) */
function runUsMomentumBacktest() {
try {
showSpinner(true);
let start = $('usmom_start')?.value || '';
let end = $('usmom_end')?.value || '';
if (!start) {
start = kstMonthStartIso();
setDateVal('usmom_start', start);
}
if (!end) {
end = kstTodayParts().iso;
setDateVal('usmom_end', end);
}
if (end && start && end < start) {
showSpinner(false);
alert('종료일이 시작일보다 앞섭니다.');
return;
}
const v = id => { const el = $(id); return el ? el.value : ''; };
const params = {
start,
end,
sl_pct: v('usmom_sl'),
tp_pct: v('usmom_tp'),
tp_max_pct: v('usmom_tp_max'),
slot_money: v('usmom_slot_money'),
slots: v('usmom_slots'),
total_budget_krw: v('usmom_total_budget') || '0',
cooldown_min: v('usmom_cooldown'),
shoulder_min_high: v('usmom_smin'),
shoulder_cut_pct: v('usmom_scut'),
trail_pct: v('usmom_trail') || '0',
trail_arm_pct: v('usmom_trail_arm') || '0',
max_hold_bars: v('usmom_max_hold') || '0',
ratchet_tiers: ($('usmom_ratchet')?.value || '').trim(),
time_start: v('usmom_time_start') || '2230',
time_end: v('usmom_time_end') || '500',
max_daily: v('usmom_max_daily') || '5',
high_chase_thr: v('usmom_high_chase') || '0.96',
max_daily_chg: v('usmom_max_daily_chg') || '20',
min_price: v('usmom_min_price') || '1',
max_loss_krw: v('usmom_max_loss') || '0',
min_margin: v('usmom_min_margin') || '0.2',
use_defense_filters: $('usmom_use_defense')?.checked ? 1 : 0,
use_high_chase_filter: $('usmom_use_high_chase_f')?.checked ? 1 : 0,
use_daily_range_filter: $('usmom_use_daily_range')?.checked ? 1 : 0,
use_ema_filter: $('usmom_use_ema_f')?.checked ? 1 : 0,
use_rsi_max_filter: $('usmom_use_rsi_max_f')?.checked ? 1 : 0,
pattern_breakout: $('usmom_pat_breakout')?.checked ? 1 : 0,
pattern_pullback: $('usmom_pat_pullback')?.checked ? 1 : 0,
chase_lookback_min: v('usmom_chase_lookback') || '10',
pullback_lookback_min: v('usmom_pullback_lookback') || '15',
pullback_min_pct: v('usmom_pullback_min') || '0.3',
pullback_max_pct: v('usmom_pullback_max') || '3.0',
setup_vol_max_mult: v('usmom_setup_vol') || '0.8',
setup_bear_bars_min: v('usmom_setup_bear') || '1',
ema_fast_period: v('usmom_ema_fast') || '9',
ema_slow_period: v('usmom_ema_slow') || '21',
mom_rsi_min: v('usmom_rsi_min'),
mom_rsi_max: v('usmom_rsi_max'),
mom_vol_mult: v('usmom_vol_mult'),
mom_vol_win: v('usmom_vol_win') || '5',
mom_time_end: v('usmom_time_end') || '500',
mom_max_from_open_pct: v('usmom_max_from_open') || '999',
mom_min_from_open_pct: v('usmom_min_from_open') || '-999',
eod_enabled: $('usmom_eod_enabled')?.checked ? 1 : 0,
eod_hm: ($('usmom_eod_hm') && $('usmom_eod_hm').value.trim()) || '05:00',
backtest_skip_pre_subscribe: 0,
daily_profit_enabled: $('usmom_daily_profit_enabled')?.checked ? 1 : 0,
daily_risk_budget_enabled: $('usmom_daily_risk_budget_enabled')?.checked ? 1 : 0,
daily_trail_tiers: ($('usmom_daily_trail_tiers')?.value || '').trim(),
daily_profit_mode: ($('usmom_daily_profit_mode')?.value || 'trailing'),
codes: ($('usmom_bt_codes')?.value || '').trim(),
};
const qs = new URLSearchParams(params).toString();
fetch('/api/backtest/us_momentum?' + qs)
.then(async r => {
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.error || ('HTTP ' + r.status));
return d;
})
.then(d => { showSpinner(false); renderUsMomentumBacktest(d); })
.catch(err => {
showSpinner(false);
alert('해외 모멘텀 백테 오류: ' + err);
});
} catch (err) {
showSpinner(false);
alert('해외 모멘텀 실행 오류: ' + err);
}
}
function renderUsMomentumBacktest(d) {
if (!d || d.error) {
alert('❌ ' + (d && d.error ? d.error : '백테스트 응답 없음'));
return;
}
const s = d.summary || {};
const trades = d.trades || [];
const p = d.params || {};
const fxRates = d.fx_rates || {};
const fxEnd = Number(s.fx_rate_end || 0) || getDisplayFxRate();
if (fxEnd > 0) setDisplayFxRate(fxEnd);
const ra = $('usmom_result_area');
if (ra) ra.style.display = 'block';
const pb = $('usmom_params_bar');
if (pb) {
pb.style.display = 'block';
const sess = p.time_window
|| `${p.time_start_hm ?? '?'}~${p.mom_time_end_hm ?? p.mom_time_end ?? p.time_end_hm ?? '?'}`;
const slot = Number(p.slot_money || 0);
const bud = Number(p.total_budget_krw || 0);
const slotTxt = fxEnd > 0 ? fmtUsdWithKrwText(slot, fxEnd) : fmtUsd(slot);
const budTxt = fxEnd > 0 ? fmtUsdWithKrwText(bud, fxEnd) : fmtUsd(bud);
const fxNote = fxEnd > 0 ? ` · FX ${fxEnd.toFixed(2)}원/$` : '';
pb.innerHTML =
`market=${p.market || 'US'} | 유니버스=${p.universe_source || 'permanent_us'} | ` +
`종목 ${p.codes_analyzed || '—'} · 1회 ${slotTxt} · 한도 ${budTxt} · ` +
`세션 ${sess}` +
(p.max_hold_bars != null ? ` · max_hold=${p.max_hold_bars}` : '') +
(p.start && p.end ? ` · ${p.start}~${p.end}` : '') +
fxNote;
}
const ctx = $('usmom_trade_context');
if (ctx) {
fillTradePnLContext(ctx, {
label: '백테 US_MOMENTUM',
totalBudget: Number(p.total_budget_krw || 0),
peakCum: Number(s.peak_cum_pnl || 0),
peakAt: s.peak_cum_at || '',
totalPnl: s.total_pnl,
currency: 'USD',
fxRate: fxEnd,
});
}
const setTxt = (id, txt) => { const el = $(id); if (el) el.textContent = txt; };
const setHtml = (id, html) => { const el = $(id); if (el) el.innerHTML = html; };
setTxt('usmom_total', (s.total_trades || 0) + '건');
setTxt('usmom_winrate', (s.win_rate || 0) + '%');
colorPnl($('usmom_winrate'), (s.win_rate || 0) - 50);
if (s.total_pnl_krw != null && Number.isFinite(Number(s.total_pnl_krw))) {
setHtml('usmom_pnl', `${fmtUsd(s.total_pnl)} <span style="color:var(--muted);font-size:12px">(${fmtWon(s.total_pnl_krw)}원)</span>`);
} else {
setHtml('usmom_pnl', fmtUsdWithKrw(s.total_pnl, fxEnd));
}
colorPnl($('usmom_pnl'), s.total_pnl);
setTxt('usmom_pf', (s.profit_factor || 0) >= 999 ? '∞' : String(s.profit_factor || 0));
colorPnl($('usmom_pf'), (s.profit_factor || 0) - 1);
if (s.max_drawdown_krw != null && Number.isFinite(Number(s.max_drawdown_krw))) {
setHtml('usmom_mdd', `-${fmtUsd(s.max_drawdown)} <span style="color:var(--muted);font-size:12px">(${fmtWon(s.max_drawdown_krw)}원)</span>`);
} else {
setTxt('usmom_mdd', '-' + fmtUsdWithKrwText(s.max_drawdown, fxEnd));
}
setTxt('usmom_hold', (s.avg_hold_min || 0) + '분');
const usSign = v => (v > 0 ? '+' : '');
setTxt('usmom_bot_pct', usSign(s.bot_pct || 0) + (s.bot_pct || 0) + '%');
colorPnl($('usmom_bot_pct'), s.bot_pct || 0);
setTxt('usmom_daily_avg_pct', usSign(s.daily_avg_pct || 0) + (s.daily_avg_pct || 0) + '%');
colorPnl($('usmom_daily_avg_pct'), s.daily_avg_pct || 0);
const winbar = $('usmom_winbar_card');
if ((s.total_trades || 0) > 0) {
if (winbar) winbar.style.display = 'block';
const wr = s.win_rate || 0;
setTxt('usmom_win_label', `🟢 승 ${s.win_trades || 0}건 (${wr}%)`);
setTxt('usmom_loss_label', `🔴 패 ${s.loss_trades || 0}건 (${(100 - wr).toFixed(1)}%)`);
const rg = $('usmom_ratio_g'); if (rg) rg.style.width = wr + '%';
const rr = $('usmom_ratio_r'); if (rr) rr.style.width = (100 - wr) + '%';
} else if (winbar) {
winbar.style.display = 'none';
}
try {
if (d.equity && d.equity.length) {
lineChart('usmom_equity_chart',
d.equity.map(e => e.date),
d.equity.map(e => e.cum_pnl),
'가상누적손익($)', '#3fb950');
} else {
destroyChart('usmom_equity_chart');
}
const rKeys = Object.keys(d.reasons || {});
if (rKeys.length) {
doughnutChart('usmom_reason_chart', rKeys, rKeys.map(k => d.reasons[k]));
} else {
destroyChart('usmom_reason_chart');
}
if (d.daily && d.daily.length) {
barChart('usmom_daily_chart',
d.daily.map(e => e.date.slice(5)),
d.daily.map(e => e.pnl));
} else {
destroyChart('usmom_daily_chart');
}
} catch (chartErr) {
console.error('usmom chart render', chartErr);
}
renderVirtualTrades('usmom_tbody', trades || [], {
showDebug: true,
showCumulative: true,
totalBudget: Number(p.total_budget_krw || 0),
currency: 'USD',
fxRates,
fxRate: fxEnd,
});
if (ra) ra.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function usmomGoOptuna() {
/* Optuna 탭 = 전역 US_MOMENTUM_* 만 (종목 cfg는 이 탭 종목 Optuna) */
document.querySelectorAll('.opt-strat-cb').forEach(cb => {
cb.checked = (cb.value === 'us_momentum');
});
const el = document.querySelector('[data-tab="optuna"]');
if (el) el.click();
}
/** 해외 모멘텀 → 실거래 분석 탭 (국내 전략 탭과 동일: 전략탭에 실거래 UI 없음) */
function usmomActGoActualTab() {
const radio = document.querySelector('input[name=act_strategy][value=US_MOMENTUM]');
if (radio) radio.checked = true;
const day = ($('act_start')?.value || '').trim() || kstTradingDayIso(_krHolidays);
setDateVal('act_start', day);
setDateVal('act_end', day);
const el = document.querySelector('[data-tab="actual"]');
if (el) el.click();
loadActual();
}
/** @deprecated 임베드 실거래/페이퍼 UI 제거 — 실거래 분석 탭으로 */
function loadUsMomActual() { usmomActGoActualTab(); }
function loadUsMomPaper() { usmomActGoActualTab(); }
let _usmomStockCache = [];
function usmomLoadStocks(seed) {
const q = seed ? '?seed=1' : '';
fetch('/api/us_momentum/stocks' + q)
.then(r => r.json())
.then(d => {
_usmomStockCache = d.stocks || [];
const sel = $('usmom_stock_code');
const tb = $('usmom_stock_tbody');
if (sel) {
const cur = sel.value;
sel.innerHTML = _usmomStockCache.map(s =>
`<option value="${s.code}">${s.code} (${s.stock_group || 'STOCK'})</option>`
).join('') || '<option value="">—</option>';
if (cur && [...sel.options].some(o => o.value === cur)) sel.value = cur;
}
if (tb) {
if (!_usmomStockCache.length) {
tb.innerHTML = '<tr><td colspan="5" class="text-muted">영구구독 US 없음</td></tr>';
} else {
tb.innerHTML = _usmomStockCache.map(s =>
`<tr style="cursor:pointer" onclick="usmomPickStock('${s.code}')">
<td>${s.code}</td><td>${s.stock_group || ''}</td><td>${s.exchange || ''}</td>
<td>${s.has_stock_cfg ? '✅' : '—'}</td>
<td>${s.enabled ? 'ON' : 'OFF'}</td></tr>`
).join('');
}
}
if (sel && sel.value) usmomOnStockSelect();
})
.catch(e => {
const tb = $('usmom_stock_tbody');
if (tb) tb.innerHTML = `<tr><td colspan="5" class="text-danger">${e}</td></tr>`;
});
}
function usmomPickStock(code) {
const sel = $('usmom_stock_code');
if (sel) sel.value = code;
usmomOnStockSelect();
}
function usmomOnStockSelect() {
const code = ($('usmom_stock_code')?.value || '').trim().toUpperCase();
if (!code) return;
const meta = (_usmomStockCache || []).find(s => s.code === code) || {};
if ($('usmom_stock_group')) $('usmom_stock_group').value = meta.stock_group || 'STOCK';
if ($('usmom_stock_exch')) $('usmom_stock_exch').value = meta.exchange || 'NASD';
const badge = $('usmom_stock_cfg_badge');
fetch('/api/us_momentum/stock_cfg?code=' + encodeURIComponent(code))
.then(r => r.json())
.then(d => {
if (badge) {
badge.textContent = d.has_stock_cfg
? `핀 있음 · ${code}`
: `전역 폴백 · ${code}`;
badge.style.color = d.has_stock_cfg ? 'var(--accent,#58a6ff)' : 'var(--muted)';
}
const c = d.cfg || {};
const setN = (id, v) => { const el = $(id); if (el && v != null && v !== '') el.value = v; };
setN('usmom_sl', c.sl_pct);
setN('usmom_tp', c.tp_pct);
setN('usmom_tp_max', c.tp_max_pct);
setN('usmom_smin', c.shoulder_min_high_pct);
setN('usmom_scut', c.shoulder_cut_pct);
setN('usmom_trail', c.trail_pct);
setN('usmom_trail_arm', c.trail_arm_pct);
setN('usmom_max_hold', c.max_hold_bars);
if (c.ratchet_tiers != null && $('usmom_ratchet')) $('usmom_ratchet').value = c.ratchet_tiers;
if (c.cooldown_sec != null && $('usmom_cooldown'))
$('usmom_cooldown').value = String(Math.round(Number(c.cooldown_sec) / 60 * 10) / 10);
setN('usmom_max_daily', c.max_daily);
setN('usmom_slot_money', c.slot_money);
setN('usmom_rsi_min', c.rsi_min);
setN('usmom_rsi_max', c.rsi_max);
setN('usmom_vol_mult', c.vol_mult);
setN('usmom_vol_win', c.vol_win);
setN('usmom_chase_lookback', c.chase_lookback_min);
setN('usmom_pullback_lookback', c.pullback_lookback_min);
setN('usmom_pullback_min', c.pullback_min_pct);
setN('usmom_pullback_max', c.pullback_max_pct);
if ($('usmom_bt_codes')) $('usmom_bt_codes').value = code;
})
.catch(e => console.warn('[usmom stock_cfg]', e));
}
function usmomSaveStockCfg() {
const code = ($('usmom_stock_code')?.value || '').trim().toUpperCase();
if (!code) { alert('종목을 선택하세요'); return; }
const numOrNull = (id) => {
const el = $(id);
if (!el || el.value === '' || el.value == null) return null;
const n = Number(el.value);
return Number.isFinite(n) ? n : null;
};
const body = {
code,
exchange: ($('usmom_stock_exch')?.value || 'NASD').trim(),
symbol: code,
name: code,
stock_group: ($('usmom_stock_group')?.value || 'STOCK'),
sl_pct: numOrNull('usmom_sl'),
tp_pct: numOrNull('usmom_tp'),
tp_max_pct: numOrNull('usmom_tp_max'),
shoulder_min_high_pct: numOrNull('usmom_smin'),
shoulder_cut_pct: numOrNull('usmom_scut'),
trail_pct: numOrNull('usmom_trail'),
trail_arm_pct: numOrNull('usmom_trail_arm'),
ratchet_tiers: ($('usmom_ratchet')?.value || '').trim() || null,
max_hold_bars: numOrNull('usmom_max_hold'),
cooldown_sec: (() => {
const m = numOrNull('usmom_cooldown');
return m == null ? null : Math.round(m * 60);
})(),
max_daily: numOrNull('usmom_max_daily'),
slot_money: numOrNull('usmom_slot_money'),
rsi_min: numOrNull('usmom_rsi_min'),
rsi_max: numOrNull('usmom_rsi_max'),
vol_mult: numOrNull('usmom_vol_mult'),
vol_win: numOrNull('usmom_vol_win'),
chase_lookback_min: numOrNull('usmom_chase_lookback'),
pullback_lookback_min: numOrNull('usmom_pullback_lookback'),
pullback_min_pct: numOrNull('usmom_pullback_min'),
pullback_max_pct: numOrNull('usmom_pullback_max'),
setup_vol_max_mult: numOrNull('usmom_setup_vol'),
setup_bear_bars_min: numOrNull('usmom_setup_bear'),
high_chase_thr: numOrNull('usmom_high_chase'),
max_daily_chg: numOrNull('usmom_max_daily_chg'),
min_price: numOrNull('usmom_min_price'),
ema_fast_period: numOrNull('usmom_ema_fast'),
ema_slow_period: numOrNull('usmom_ema_slow'),
use_defense_filters: $('usmom_use_defense')?.checked ? 1 : 0,
use_high_chase_filter: $('usmom_use_high_chase_f')?.checked ? 1 : 0,
use_daily_range_filter: $('usmom_use_daily_range')?.checked ? 1 : 0,
use_ema_filter: $('usmom_use_ema_f')?.checked ? 1 : 0,
use_rsi_max_filter: $('usmom_use_rsi_max_f')?.checked ? 1 : 0,
pattern_breakout: $('usmom_pat_breakout')?.checked ? 1 : 0,
pattern_pullback: $('usmom_pat_pullback')?.checked ? 1 : 0,
};
fetch('/api/us_momentum/stock_cfg', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
.then(r => r.json())
.then(d => {
if (d.error) throw new Error(d.error);
alert('✅ ' + code + ' 종목 파람 저장');
usmomLoadStocks(false);
})
.catch(e => alert('종목 저장 실패: ' + e));
}
function usmomClearStockCfg() {
const code = ($('usmom_stock_code')?.value || '').trim().toUpperCase();
if (!code) return;
if (!confirm(code + ' 종목 핀을 삭제하고 전역 US_MOMENTUM_* 로 복귀할까요?')) return;
fetch('/api/us_momentum/stock_cfg?code=' + encodeURIComponent(code), { method: 'DELETE' })
.then(r => r.json())
.then(d => {
if (d.error) throw new Error(d.error);
usmomLoadStocks(false);
})
.catch(e => alert('삭제 실패: ' + e));
}
const USMOM_OPT_LS_KEY = 'kis_usmom_stock_optuna_job';
let _usmomOptJobId = localStorage.getItem(USMOM_OPT_LS_KEY) || '';
let _usmomOptPollTimer = null;
/** @type {{source:string, rank:number}|null} */
let _usmomOptSelected = null;
window._usmomOptLastSummary = null;
function usmomOptSetStatus(html, isErr) {
const el = $('usmom_opt_status');
if (!el) return;
el.innerHTML = html;
el.style.color = isErr ? 'var(--danger, #f85149)' : 'var(--muted)';
}
function usmomOptSyncSelectedButtons() {
const has = !!(
_usmomOptSelected && _usmomOptSelected.source && _usmomOptSelected.rank
);
const fl = $('usmom_opt_btn_fill_sel');
const ap = $('usmom_opt_btn_apply_sel');
if (fl) fl.disabled = !has;
if (ap) ap.disabled = !has;
const sel = $('usmom_opt_detail_sel');
if (sel) {
sel.textContent = has
? `${_usmomOptSelected.source} #${_usmomOptSelected.rank}`
: '미선택';
}
}
function usmomOptSetButtons(running, done) {
const st = $('usmom_opt_btn_start');
const sp = $('usmom_opt_btn_stop');
const ap = $('usmom_opt_btn_apply');
const fl = $('usmom_opt_btn_fill');
if (st) st.disabled = !!running;
if (sp) sp.disabled = !running;
if (ap) ap.disabled = !done;
if (fl) fl.disabled = !done;
const box = $('usmom_opt_results_box');
if (box) {
if (done) {
box.style.display = 'block';
box.dataset.forceShow = '1';
} else if (running) {
box.style.display = 'none';
box.dataset.forceShow = '';
}
}
usmomOptSyncSelectedButtons();
}
function usmomOptFmtUsdOrNum(v, digits) {
const n = Number(v);
if (!Number.isFinite(n)) return '—';
if (digits == null) return n.toLocaleString(undefined, { maximumFractionDigits: 2 });
return n.toFixed(digits);
}
function usmomOptRenderTopTable(tbodyId, rows, source) {
const tb = $(tbodyId);
if (!tb) return;
if (!rows || !rows.length) {
tb.innerHTML = `<tr><td colspan="7" class="text-muted">${source} 후보 없음</td></tr>`;
return;
}
const src = source;
tb.innerHTML = rows.map(r => {
const rank = r.rank || 1;
const sel = _usmomOptSelected
&& _usmomOptSelected.source === src
&& Number(_usmomOptSelected.rank) === Number(rank);
const outline = sel ? 'outline:1px solid var(--accent)' : '';
return `<tr style="${outline}">
<td>${rank}</td>
<td>#${r.optuna_trial_number ?? '—'}</td>
<td class="text-end">${usmomOptFmtUsdOrNum(r.total_trades, 0)}</td>
<td class="text-end">${usmomOptFmtUsdOrNum(r.win_rate, 1)}%</td>
<td class="text-end">${usmomOptFmtUsdOrNum(r.pf, 2)}</td>
<td class="text-end ${Number(r.total_pnl) >= 0 ? 'text-pnl-pos' : 'text-pnl-neg'}">${usmomOptFmtUsdOrNum(r.total_pnl)}</td>
<td class="text-nowrap">
<button type="button" class="btn btn-sm btn-outline-secondary py-0" onclick="usmomStockOptunaView('${src}',${rank})">보기</button>
<button type="button" class="btn btn-sm btn-outline-info py-0" onclick="usmomStockOptunaFillForm('${src}',${rank})">폼</button>
<button type="button" class="btn btn-sm btn-outline-success py-0" onclick="usmomStockOptunaApply('${src}',${rank})">적용</button>
</td>
</tr>`;
}).join('');
}
function usmomStockOptunaRenderResults(sum) {
window._usmomOptLastSummary = sum || null;
const box = $('usmom_opt_results_box');
if (!sum) {
if (box) box.style.display = 'none';
return;
}
if (box) {
box.style.display = 'block';
box.dataset.forceShow = '1';
}
usmomOptRenderTopTable('usmom_opt_top5_learn', sum.top5_learn || [], 'learn');
usmomOptRenderTopTable('usmom_opt_top5_gated', sum.top5_gated || [], 'gated');
}
function usmomOptSyncDatesFromMain() {
const s = ($('usmom_start')?.value || '').trim();
const e = ($('usmom_end')?.value || '').trim();
if ($('usmom_opt_start') && s) $('usmom_opt_start').value = s;
if ($('usmom_opt_end') && e) $('usmom_opt_end').value = e;
}
function usmomOptSyncDatesToMain() {
const s = ($('usmom_opt_start')?.value || '').trim();
const e = ($('usmom_opt_end')?.value || '').trim();
if ($('usmom_start') && s) setDateVal('usmom_start', s);
if ($('usmom_end') && e) setDateVal('usmom_end', e);
}
function usmomStockOptunaRender(job) {
if (!job) return;
const st = job.status || '';
const prog = job.progress || {};
const pct = Number(prog.pct || 0);
const doneN = prog.trials_done ?? 0;
const totN = prog.trials_total ?? job.trials ?? '—';
const fill = $('usmom_opt_fill');
if (fill) {
fill.style.width = `${Math.min(100, Math.max(0, pct))}%`;
fill.classList.toggle('is-done', st === 'done');
fill.classList.toggle('is-error', st === 'error');
}
if ($('usmom_opt_prog_label')) {
const met = optunaBestMetricsText(prog, job);
$('usmom_opt_prog_label').textContent =
`trial ${doneN} / ${totN} (${pct}%)` +
(job.symbol ? ` · ${job.symbol}` : '') +
(met ? ` · ${met}` : '');
}
const log = job.log_path ? `<br><code style="font-size:11px">${job.log_path}</code>` : '';
usmomOptSetStatus(
`<b>${job.label || '종목 Optuna'}</b> · ${st}` +
(job.error ? `<br>❌ ${job.error}` : '') +
(job.result_json ? `<br>JSON: <code style="font-size:11px">${job.result_json}</code>` : '') +
log,
st === 'error'
);
const tail = $('usmom_opt_log_tail');
if (tail) {
const t = (job.log_tail || '').trim();
if (t) {
tail.textContent = t;
tail.classList.remove('d-none');
} else if (st !== 'running') {
tail.classList.add('d-none');
}
}
usmomOptSetButtons(st === 'running', st === 'done');
if (st === 'done' && job.result_summary) {
usmomStockOptunaRenderResults(job.result_summary);
}
// 상단 네비 프로그레스도 동일 갱신 (Optuna 탭과 공유 바)
if (typeof optunaSetNav === 'function') optunaSetNav(job);
}
async function usmomStockOptunaView(source, rank) {
if (!_usmomOptJobId) {
_usmomOptJobId = localStorage.getItem(USMOM_OPT_LS_KEY) || '';
}
if (!_usmomOptJobId) { alert('종목 Optuna job 없음'); return; }
const src = source || 'learn';
const rk = Number(rank) || 1;
_usmomOptSelected = { source: src, rank: rk };
usmomOptSyncSelectedButtons();
const title = $('usmom_opt_detail_title');
const met = $('usmom_opt_detail_metrics');
const pre = $('usmom_opt_detail_params');
if (title) title.textContent = `로딩 ${src} #${rk}`;
if (met) met.textContent = '불러오는 중…';
try {
const qs = new URLSearchParams({
job_id: _usmomOptJobId,
source: src,
rank: String(rk),
});
const r = await fetch('/api/optuna/candidate?' + qs.toString());
const j = await r.json();
if (!j.ok) {
if (met) met.textContent = '❌ ' + (j.error || '실패');
if (pre) { pre.textContent = '—'; pre.classList.add('d-none'); }
return;
}
const m = j.metrics || {};
if (title) {
title.textContent =
`${j.strategy || 'us_momentum'} · ${m.label || src} · trial ${
m.optuna_trial_number != null ? '#' + m.optuna_trial_number : '—'
}`;
}
if (met) {
met.innerHTML =
`거래 <b>${usmomOptFmtUsdOrNum(m.total_trades, 0)}</b> · ` +
`승률 <b>${usmomOptFmtUsdOrNum(m.win_rate, 1)}%</b> · ` +
`PF <b>${usmomOptFmtUsdOrNum(m.pf, 2)}</b> · ` +
`PnL <b class="${Number(m.total_pnl) >= 0 ? 'text-pnl-pos' : 'text-pnl-neg'}">${usmomOptFmtUsdOrNum(m.total_pnl)}</b>` +
(j.start && j.end ? ` · ${j.start}~${j.end}` : '');
}
if (pre) {
pre.textContent = JSON.stringify(j.params_preview || j.params_full || {}, null, 2);
pre.classList.remove('d-none');
}
window._optunaJobId = _usmomOptJobId;
window._optunaCandidateCache = {
strategy: j.strategy || 'us_momentum',
start: j.start,
end: j.end,
params: j.params_full || j.params_preview || {},
};
usmomStockOptunaRenderResults(window._usmomOptLastSummary);
} catch (e) {
if (met) met.textContent = '오류: ' + e;
}
}
async function usmomStockOptunaFillSelected() {
if (!_usmomOptSelected) { alert('먼저 Top에서「보기」로 후보를 고르세요'); return; }
return usmomStockOptunaFillForm(_usmomOptSelected.source, _usmomOptSelected.rank);
}
async function usmomStockOptunaApplySelected() {
if (!_usmomOptSelected) { alert('먼저 Top에서「보기」로 후보를 고르세요'); return; }
return usmomStockOptunaApply(_usmomOptSelected.source, _usmomOptSelected.rank);
}
async function usmomStockOptunaStart() {
const code = ($('usmom_stock_code')?.value || '').trim().toUpperCase();
if (!code) { alert('종목을 선택하세요'); return; }
usmomOptSyncDatesFromMain();
let start = ($('usmom_opt_start')?.value || '').trim() || ($('usmom_start')?.value || '').trim();
let end = ($('usmom_opt_end')?.value || '').trim() || ($('usmom_end')?.value || '').trim();
if (!start || !end) { alert('시작·종료일을 입력하세요'); return; }
usmomOptSyncDatesToMain();
if ($('usmom_bt_codes')) $('usmom_bt_codes').value = code;
const trials = parseInt($('usmom_opt_trials')?.value || '100', 10) || 100;
const mode = ($('usmom_opt_mode')?.value || 'tpe').trim() || 'tpe';
const candleSrc = $('opt_candle_source')?.value || '';
const tickSrc = $('opt_tick_source')?.value || '';
const obSrc = $('opt_ob_source')?.value || '';
if (!confirm(
`종목 Optuna 시작?\n${code} (1종목 유니버스)\n${start}~${end} trials=${trials}\n` +
`완료 후 「폼에 넣기」→백테 검증 · 「종목행에 적용」→ stock_config\n(전역 US_MOMENTUM_* 미적용)`
)) return;
try {
const r = await fetch('/api/optuna/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
strategy: 'us_momentum',
symbol: code,
start, end, trials, mode,
candle_source: candleSrc,
tick_source: tickSrc,
ob_source: obSrc,
}),
});
const j = await r.json();
if (!j.ok) { alert('❌ ' + (j.error || '시작 실패')); return; }
_usmomOptJobId = j.job.job_id;
localStorage.setItem(USMOM_OPT_LS_KEY, _usmomOptJobId);
usmomStockOptunaRender(j.job);
usmomStockOptunaStartPolling();
} catch (e) {
alert('오류: ' + e);
}
}
async function usmomStockOptunaStop() {
if (!_usmomOptJobId) return;
if (!confirm('종목 Optuna를 중지할까요?')) return;
try {
const r = await fetch('/api/optuna/stop/' + encodeURIComponent(_usmomOptJobId), { method: 'POST' });
const j = await r.json();
if (!j.ok) { alert(j.error || '중지 실패'); return; }
usmomStockOptunaRender(j.job);
if (_usmomOptPollTimer) { clearInterval(_usmomOptPollTimer); _usmomOptPollTimer = null; }
} catch (e) { alert('오류: ' + e); }
}
async function usmomStockOptunaPollOnce() {
if (!_usmomOptJobId) return;
try {
const r = await fetch('/api/optuna/status/' + encodeURIComponent(_usmomOptJobId));
const j = await r.json();
if (!j.ok) {
usmomOptSetStatus('job 없음', true);
_usmomOptJobId = '';
localStorage.removeItem(USMOM_OPT_LS_KEY);
usmomOptSetButtons(false, false);
const fill = $('usmom_opt_fill');
if (fill) { fill.style.width = '0%'; fill.classList.remove('is-done', 'is-error'); }
if ($('usmom_opt_prog_label')) $('usmom_opt_prog_label').textContent = 'trial —';
return;
}
usmomStockOptunaRender(j.job);
const st = j.job.status;
if (st === 'done' || st === 'error') {
if (_usmomOptPollTimer) { clearInterval(_usmomOptPollTimer); _usmomOptPollTimer = null; }
}
} catch (e) {
usmomOptSetStatus('폴링 오류: ' + e, true);
}
}
function usmomStockOptunaStartPolling() {
if (_usmomOptPollTimer) clearInterval(_usmomOptPollTimer);
usmomStockOptunaPollOnce();
_usmomOptPollTimer = setInterval(usmomStockOptunaPollOnce, 4000);
}
async function usmomStockOptunaFillForm(source, rank) {
/* source/rank 없으면 학습1위(진행바 best). Top「폼」·선택 버튼에서 지정 가능. */
if (!_usmomOptJobId) {
_usmomOptJobId = localStorage.getItem(USMOM_OPT_LS_KEY) || '';
}
if (!_usmomOptJobId) { alert('완료된 종목 Optuna job 없음'); return; }
const src = (source || 'learn').toString();
const rk = Number(rank) || 1;
_usmomOptSelected = { source: src, rank: rk };
usmomOptSyncSelectedButtons();
const code = ($('usmom_stock_code')?.value || '').trim().toUpperCase()
|| ($('usmom_bt_codes')?.value || '').trim().toUpperCase();
try {
const qs = new URLSearchParams({
job_id: _usmomOptJobId,
source: src,
rank: String(rk),
});
const r = await fetch('/api/optuna/candidate?' + qs.toString());
const j = await r.json();
if (!j.ok) { alert('❌ ' + (j.error || '후보 없음')); return; }
window._optunaJobId = _usmomOptJobId;
window._optunaCandidateCache = {
strategy: j.strategy || 'us_momentum',
start: j.start,
end: j.end,
params: j.params_full || j.params_preview || {},
};
// 이미 🇺🇸탭 — tab.click 스킵 (핀 cfg 재로드로 덮어쓰기 방지)
await optunaFillFormFromCandidate(src, rk);
if (code && $('usmom_bt_codes')) $('usmom_bt_codes').value = code;
if (j.start) {
setDateVal('usmom_start', j.start);
if ($('usmom_opt_start')) $('usmom_opt_start').value = j.start;
}
if (j.end) {
setDateVal('usmom_end', j.end);
if ($('usmom_opt_end')) $('usmom_opt_end').value = j.end;
}
} catch (e) {
alert('폼에 넣기 오류: ' + e);
}
}
async function usmomStockOptunaApply(source, rank) {
if (!_usmomOptJobId) {
_usmomOptJobId = localStorage.getItem(USMOM_OPT_LS_KEY) || '';
}
if (!_usmomOptJobId) { alert('완료된 종목 Optuna job 없음'); return; }
const code = ($('usmom_stock_code')?.value || '').trim().toUpperCase();
if (!code) { alert('종목 선택 필요'); return; }
const src = (source || 'learn').toString();
const rk = Number(rank) || 1;
_usmomOptSelected = { source: src, rank: rk };
usmomOptSyncSelectedButtons();
if (!confirm(
`${code} ${src} #${rk} → us_momentum_stock_config?\n` +
`(전역 US_MOMENTUM_* / 다단트레일 미변경)`
)) return;
try {
const r = await fetch('/api/optuna/apply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
job_id: _usmomOptJobId,
source: src,
rank: rk,
symbol: code,
exchange: ($('usmom_stock_exch')?.value || 'NASD').trim(),
stock_group: ($('usmom_stock_group')?.value || 'STOCK').trim(),
}),
});
const j = await r.json();
if (!j.ok) { alert('❌ ' + (j.error || '적용 실패')); return; }
alert(
`${code} 종목행 적용 (${src} #${rk})\n` +
`PnL=${j.metrics?.total_pnl} trades=${j.metrics?.total_trades}\n` +
(j.apply_target || '')
);
usmomLoadStocks(false);
} catch (e) {
alert('오류: ' + e);
}
}
/** US_MOMENTUM_* 만 저장 — 국내 MOMENTUM_/SCALP_ 미오염 */
function saveUsMomentumConfig() {
const body = {
sl_pct: parseFloat($('usmom_sl')?.value),
tp_pct: parseFloat($('usmom_tp')?.value),
tp_max_pct: parseFloat($('usmom_tp_max')?.value),
slot_money: parseInt($('usmom_slot_money')?.value || '0', 10) || 0,
slots: parseInt($('usmom_slots')?.value || '4', 10) || 4,
total_budget_krw: parseInt($('usmom_total_budget')?.value || '0', 10),
cooldown_min: parseFloat($('usmom_cooldown')?.value || '0'),
shoulder_min_high: parseFloat($('usmom_smin')?.value),
shoulder_cut_pct: parseFloat($('usmom_scut')?.value),
trail_pct: parseFloat($('usmom_trail')?.value || '0'),
trail_arm_pct: parseFloat($('usmom_trail_arm')?.value || '0'),
max_hold_bars: parseInt($('usmom_max_hold')?.value || '0', 10),
ratchet_tiers: ($('usmom_ratchet')?.value || '').trim(),
time_start: parseInt($('usmom_time_start')?.value || '2230', 10),
time_end: parseInt($('usmom_time_end')?.value || '500', 10),
sell_time_end: parseInt($('usmom_sell_time_end')?.value || '630', 10),
max_daily: parseInt($('usmom_max_daily')?.value || '5', 10),
high_chase_thr: parseFloat($('usmom_high_chase')?.value || '0.96'),
max_daily_chg: parseFloat($('usmom_max_daily_chg')?.value || '20'),
min_price: parseFloat($('usmom_min_price')?.value || '1'),
max_loss_krw: parseInt($('usmom_max_loss')?.value || '0', 10),
min_margin: parseFloat($('usmom_min_margin')?.value || '0.2'),
use_defense_filters: !!($('usmom_use_defense')?.checked),
use_high_chase_filter: !!($('usmom_use_high_chase_f')?.checked),
use_daily_range_filter: !!($('usmom_use_daily_range')?.checked),
use_ema_filter: !!($('usmom_use_ema_f')?.checked),
use_rsi_max_filter: !!($('usmom_use_rsi_max_f')?.checked),
pattern_breakout: !!($('usmom_pat_breakout')?.checked),
pattern_pullback: !!($('usmom_pat_pullback')?.checked),
chase_lookback_min: parseInt($('usmom_chase_lookback')?.value || '10', 10),
pullback_lookback_min: parseInt($('usmom_pullback_lookback')?.value || '15', 10),
pullback_min_pct: parseFloat($('usmom_pullback_min')?.value || '0.3'),
pullback_max_pct: parseFloat($('usmom_pullback_max')?.value || '3.0'),
setup_vol_max_mult: parseFloat($('usmom_setup_vol')?.value || '0.8'),
setup_bear_bars_min: parseInt($('usmom_setup_bear')?.value || '1', 10),
ema_fast_period: parseInt($('usmom_ema_fast')?.value || '9', 10),
ema_slow_period: parseInt($('usmom_ema_slow')?.value || '21', 10),
mom_rsi_min: parseFloat($('usmom_rsi_min')?.value),
mom_rsi_max: parseFloat($('usmom_rsi_max')?.value),
mom_vol_mult: parseFloat($('usmom_vol_mult')?.value),
mom_vol_win: parseInt($('usmom_vol_win')?.value || '5', 10),
mom_max_from_open_pct: parseFloat($('usmom_max_from_open')?.value || '999'),
mom_min_from_open_pct: parseFloat($('usmom_min_from_open')?.value || '-999'),
daily_trail_tiers: ($('usmom_daily_trail_tiers')?.value || '').trim(),
daily_profit_mode: ($('usmom_daily_profit_mode')?.value || 'trailing'),
daily_profit_enabled: !!($('usmom_daily_profit_enabled')?.checked),
daily_risk_budget_enabled: !!($('usmom_daily_risk_budget_enabled')?.checked),
eod_enabled: !!($('usmom_eod_enabled')?.checked),
eod_hm: ($('usmom_eod_hm')?.value || '').trim() || '05:00',
};
fetch('/api/backtest/us_momentum/save_config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
.then(r => r.json())
.then(d => {
if (d.error) throw new Error(d.error);
alert('✅ 해외 모멘텀 설정 저장 (' + (d.saved_keys || []).length + '키 · US_MOMENTUM_* only)');
})
.catch(err => alert('해외 모멘텀 저장 오류: ' + err));
}
function updateUsMomFxHint() {
const apply = (fx) => {
if (fx > 0) setDisplayFxRate(fx);
const el = $('usmom_fx_hint');
if (!el) return;
const r = getDisplayFxRate();
if (!(r > 0)) {
el.textContent = '$↔원 = 표시용(캐시/폴백)';
return;
}
const slot = Number($('usmom_slot_money')?.value || 0);
const bud = Number($('usmom_total_budget')?.value || 0);
el.textContent =
`FX ${r.toFixed(2)} · 1회≈${fmtWon(Math.round(slot * r))}원 · 한도≈${fmtWon(Math.round(bud * r))}`;
};
const cached = getDisplayFxRate();
if (cached > 0) apply(cached);
fetch('/api/fx/usd_krw')
.then(r => r.json())
.then(j => {
const fx = Number(j.display_rate || 0);
if (fx > 0) apply(fx);
})
.catch(() => {});
}
function fillUsMomentumFormFromApi(m) {
if (!m || typeof m !== 'object') return;
const set = (id, val) => { if (val !== null && val !== undefined && $(id)) $(id).value = val; };
set('usmom_rsi_min', m.mom_rsi_min);
set('usmom_rsi_max', m.mom_rsi_max);
set('usmom_vol_mult', m.mom_vol_mult);
set('usmom_vol_win', m.mom_vol_win);
set('usmom_time_start', m.mom_time_start_hm || m.time_start);
set('usmom_time_end', m.mom_time_end_hm || m.time_end);
set('usmom_sell_time_end', m.sell_time_end);
if (m.eod_enabled !== undefined && $('usmom_eod_enabled')) {
$('usmom_eod_enabled').checked = !!m.eod_enabled;
}
if (m.eod_hm != null) set('usmom_eod_hm', m.eod_hm);
set('usmom_sl', m.sl_pct);
set('usmom_tp', m.tp_pct);
set('usmom_tp_max', m.tp_max_pct);
set('usmom_smin', m.shoulder_min_high);
set('usmom_scut', m.shoulder_cut_pct);
if (m.trail_pct != null) set('usmom_trail', m.trail_pct);
if (m.trail_arm_pct != null) set('usmom_trail_arm', m.trail_arm_pct);
if (m.max_hold_bars != null) set('usmom_max_hold', m.max_hold_bars);
if ($('usmom_ratchet')) $('usmom_ratchet').value = (m.ratchet_tiers != null ? String(m.ratchet_tiers) : '');
set('usmom_cooldown', m.cooldown_min);
set('usmom_max_daily', m.max_daily);
set('usmom_slot_money', m.slot_money);
set('usmom_slots', m.mom_slots || m.slots);
if (m.total_budget_krw != null) set('usmom_total_budget', m.total_budget_krw);
set('usmom_high_chase', m.high_chase_thr);
set('usmom_max_daily_chg', m.max_daily_chg);
set('usmom_min_price', m.min_price);
set('usmom_max_loss', m.max_loss_krw);
set('usmom_min_margin', m.min_margin);
set('usmom_max_from_open', m.mom_max_from_open_pct);
set('usmom_min_from_open', m.mom_min_from_open_pct);
if ($('usmom_pat_breakout') && m.pattern_breakout !== undefined) {
$('usmom_pat_breakout').checked = !!m.pattern_breakout;
}
if ($('usmom_pat_pullback') && m.pattern_pullback !== undefined) {
$('usmom_pat_pullback').checked = !!m.pattern_pullback;
}
set('usmom_chase_lookback', m.chase_lookback_min);
set('usmom_pullback_lookback', m.pullback_lookback_min);
set('usmom_pullback_min', m.pullback_min_pct);
set('usmom_pullback_max', m.pullback_max_pct);
set('usmom_setup_vol', m.setup_vol_max_mult);
set('usmom_setup_bear', m.setup_bear_bars_min);
if (m.use_defense_filters !== undefined && $('usmom_use_defense')) {
$('usmom_use_defense').checked = !!m.use_defense_filters;
}
if ($('usmom_use_high_chase_f') && m.use_high_chase_filter !== undefined) {
$('usmom_use_high_chase_f').checked = !!m.use_high_chase_filter;
}
if ($('usmom_use_daily_range') && m.use_daily_range_filter !== undefined) {
$('usmom_use_daily_range').checked = !!m.use_daily_range_filter;
}
if ($('usmom_use_ema_f') && m.use_ema_filter !== undefined) {
$('usmom_use_ema_f').checked = !!m.use_ema_filter;
}
if ($('usmom_use_rsi_max_f') && m.use_rsi_max_filter !== undefined) {
$('usmom_use_rsi_max_f').checked = !!m.use_rsi_max_filter;
}
if (m.ema_fast_period != null) set('usmom_ema_fast', m.ema_fast_period);
if (m.ema_slow_period != null) set('usmom_ema_slow', m.ema_slow_period);
if (m.daily_profit_enabled !== undefined && $('usmom_daily_profit_enabled')) {
$('usmom_daily_profit_enabled').checked = !!m.daily_profit_enabled;
}
if (m.daily_risk_budget_enabled !== undefined && $('usmom_daily_risk_budget_enabled')) {
$('usmom_daily_risk_budget_enabled').checked = !!m.daily_risk_budget_enabled;
}
if (m.daily_trail_tiers != null) set('usmom_daily_trail_tiers', m.daily_trail_tiers);
if (m.daily_profit_mode != null) set('usmom_daily_profit_mode', m.daily_profit_mode);
updateUsMomFxHint();
}
/** 영구구독 US 전 종목 → KIS 해외 1분봉 → ws_candles.market=US */
function usmomFetchCandles(tf) {
const start = $('usmom_start')?.value || '';
const end = $('usmom_end')?.value || '';
if (!start || !end) {
alert('시작일·종료일을 먼저 넣으세요.');
return;
}
const st = $('usmom_fetch_status');
if (st) st.textContent = '수집 시작…';
fetch('/api/ws_candles/fetch_us', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ start, end, tf: tf || 1, rollup_3m: true }),
})
.then(r => r.json())
.then(d => {
if (d.error) throw new Error(d.error);
const jobId = d.job_id;
if (!jobId) throw new Error('job_id 없음');
const poll = () => {
fetch('/api/ws_candles/fetch_us/status/' + jobId)
.then(r => r.json())
.then(j => {
if (st) {
st.textContent =
`${j.status || '?'} · ${j.current || ''} saved=${j.saved || 0}` +
(j.error ? ' · ' + j.error : '');
}
if (j.status === 'running') setTimeout(poll, 1500);
else if (j.status === 'done' && st) {
st.textContent =
`완료 · 종목 ${j.codes || 0} · 저장 ${j.saved || 0}` +
(j.rollup_3m ? ` · 3m롤업 ${j.rollup_3m}` : '');
}
})
.catch(e => { if (st) st.textContent = '폴링 오류: ' + e; });
};
poll();
})
.catch(err => {
if (st) st.textContent = '실패: ' + err;
alert('US 봉 수집 오류: ' + err);
});
}
function saveMomentumConfig() {
const body = {
sl_pct: parseFloat($('mom_sl').value),
tp_pct: parseFloat($('mom_tp').value),
tp_max_pct: parseFloat($('mom_tp_max').value),
slot_money: parseInt($('mom_slot_money').value, 10) || 0,
slots: parseInt($('mom_slots').value, 10) || 3,
total_budget_krw: parseInt($('mom_total_budget')?.value || '0', 10),
cooldown_min: parseFloat($('mom_cooldown').value),
shoulder_min_high: parseFloat($('mom_smin').value),
shoulder_cut_pct: parseFloat($('mom_scut').value),
trail_pct: parseFloat($('mom_trail')?.value || '0'),
trail_arm_pct: parseFloat($('mom_trail_arm')?.value || '0'),
max_hold_bars: parseInt($('mom_max_hold')?.value || '0', 10),
ratchet_tiers: ($('mom_ratchet')?.value || '').trim(),
time_start: parseInt($('mom_time_start').value, 10),
max_daily: parseInt($('mom_max_daily').value, 10),
high_chase_thr: parseFloat($('mom_high_chase').value),
max_daily_chg: parseFloat($('mom_max_daily_chg').value),
min_price: parseFloat($('mom_min_price').value),
max_loss_krw: parseInt($('mom_max_loss_krw').value, 10),
min_margin: parseFloat($('mom_min_margin').value),
use_defense_filters: !!($('mom_use_defense')?.checked),
use_high_chase_filter: !!($('mom_use_high_chase_f')?.checked),
use_daily_range_filter: !!($('mom_use_daily_range')?.checked),
use_ema_filter: !!($('mom_use_ema_f')?.checked),
use_rsi_max_filter: !!($('mom_use_rsi_max_f')?.checked),
pattern_breakout: !!($('mom_pat_breakout')?.checked),
pattern_pullback: !!($('mom_pat_pullback')?.checked),
chase_lookback_min: parseInt($('mom_chase_lookback')?.value || '10', 10),
pullback_lookback_min: parseInt($('mom_pullback_lookback')?.value || '15', 10),
pullback_min_pct: parseFloat($('mom_pullback_min')?.value || '0.3'),
pullback_max_pct: parseFloat($('mom_pullback_max')?.value || '3.0'),
setup_vol_max_mult: parseFloat($('mom_setup_vol')?.value || '0.8'),
setup_bear_bars_min: parseInt($('mom_setup_bear')?.value || '1', 10),
ema_fast_period: parseInt($('mom_ema_fast')?.value || '9', 10),
ema_slow_period: parseInt($('mom_ema_slow')?.value || '21', 10),
mom_rsi_min: parseFloat($('mom_rsi_min').value),
mom_rsi_max: parseFloat($('mom_rsi_max').value),
mom_vol_mult: parseFloat($('mom_vol_mult').value),
mom_vol_win: parseInt($('mom_vol_win').value, 10),
e_min_chg_pct: parseFloat($('mom_e_min_chg')?.value || '0.2'),
mom_time_end: parseInt($('mom_time_end_buy').value, 10),
mom_max_from_open_pct: parseFloat($('mom_max_from_open')?.value || '999'),
mom_min_from_open_pct: parseFloat($('mom_min_from_open')?.value || '-999'),
ob_filter: !!($('mom_ob_filter')?.checked),
pg_filter: !!($('mom_pg_filter')?.checked),
daily_trail_tiers: ($('mom_daily_trail_tiers') && $('mom_daily_trail_tiers').value.trim()) || '',
daily_profit_mode: ($('mom_daily_profit_mode') && $('mom_daily_profit_mode').value) || 'trailing',
daily_profit_enabled: !!($('mom_daily_profit_enabled')?.checked),
daily_risk_budget_enabled: !!($('mom_daily_risk_budget_enabled')?.checked),
eod_enabled: !!($('mom_eod_enabled')?.checked),
eod_hm: ($('mom_eod_hm') && $('mom_eod_hm').value.trim()) || '15:25',
backtest_skip_pre_subscribe: !!($('mom_skip_pre_sub')?.checked),
};
if (!confirm('💾 모멘텀 봇(MOMENTUM_*)에 현재 탭 값을 저장할까요?\n실행 중이면 다음 루프부터 반영됩니다.\n다단트레일: ' + (body.daily_profit_enabled ? 'ON' : 'OFF') + ' · 리스크버짓(B): ' + (body.daily_risk_budget_enabled ? 'ON' : 'OFF'))) return;
fetch('/api/backtest/momentum/save_config', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body),
}).then(r => r.json()).then(d => {
if (d.error) { alert('❌ ' + d.error); return; }
btAddPresetOption('mom_daily_trail_preset', $('mom_daily_trail_tiers') && $('mom_daily_trail_tiers').value);
const byTbl = d.saved_by_table || {};
const tblLines = Object.entries(byTbl).map(([t, ks]) => ` ${t}: ${ks.join(', ')}`).join('\n');
alert(`✅ 저장 완료 (env_id: ${d.env_id})\n테이블:\n${tblLines || d.saved_keys?.join(', ')}`);
}).catch(e => alert('오류: ' + e));
}
function renderMomentumBacktest(d) {
if (!d || d.error) {
alert('❌ ' + (d && d.error ? d.error : '백테스트 응답 없음'));
return;
}
if (!d.summary) {
alert('백테스트 응답 오류 (summary 없음)');
return;
}
const s = d.summary || {};
const trades = d.trades || [];
const p = d.params || {};
const momRa = $('mom_result_area');
if (momRa) momRa.style.display = 'block';
const momPb = $('mom_params_bar');
if (momPb) momPb.style.display = 'block';
const setTxt = (id, txt) => { const el = $(id); if (el) el.textContent = txt; };
const momUnivLabel = formatUniverseLabel(p);
const effTp = p.effective_tp_pct != null ? p.effective_tp_pct : p.tp_pct;
const periodHint = (p.start && p.end) ? ` | ${p.start}~${p.end}` : '';
const patParts = [];
if (p.pattern_breakout) patParts.push('돌파');
if (p.pattern_pullback) patParts.push('눌림');
const patLabel = patParts.length ? patParts.join('+') : '패턴OFF';
const rsiLabel = p.use_rsi_max_filter
? `RSI ${p.mom_rsi_min}~${p.mom_rsi_max}`
: `RSI≥${p.mom_rsi_min}`;
const momSummaryLine =
`<span class="badge ${isHistoryUniverseSource(p.universe_source) ? 'badge-info' : 'badge-secondary'}" title="후보 이력(키움/LS)">${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 (t.daily_risk_budget_enabled !== undefined && $('tl_daily_risk_budget_enabled')) {
$('tl_daily_risk_budget_enabled').checked = !!t.daily_risk_budget_enabled;
}
if ($('tl_daily_trail_tiers')) {
$('tl_daily_trail_tiers').value = (t.daily_trail_tiers != null ? String(t.daily_trail_tiers) : '');
}
if (t.daily_profit_mode && $('tl_daily_profit_mode')) {
$('tl_daily_profit_mode').value = t.daily_profit_mode;
}
if ($('tl_daily_trail_arm') && t.daily_trail_arm_krw != null) {
$('tl_daily_trail_arm').value = String(t.daily_trail_arm_krw);
}
if ($('tl_daily_trail_drop') && t.daily_trail_drop_pct != null) {
$('tl_daily_trail_drop').value = String(t.daily_trail_drop_pct);
}
// DB(env)에 저장된 사용자 프리셋 목록(세미콜론) → 드롭다운 ★옵션 복원
btFillPresetOptions('tl_ratchet_preset', t.ratchet_presets);
btFillPresetOptions('tl_daily_trail_preset', t.daily_trail_presets);
tlSyncRatchetShoulderColors();
}
// 파람서치 그리드 프리셋 드롭다운 → 텍스트 입력칸 채우기.
// '__custom__'(직접입력)은 무시, 'off'는 빈칸(=단일 어깨/미사용)으로 채운다.
function btApplyPreset(targetId, val, syncColors) {
if (val === '__custom__') return;
const el = document.getElementById(targetId);
if (!el) return;
el.value = (val === 'off') ? '' : val;
if (syncColors && typeof tlSyncRatchetShoulderColors === 'function') {
tlSyncRatchetShoulderColors();
}
}
// ── 커스텀 프리셋(직접입력값) 드롭다운 ─ DB(env) 영구 저장본 기반 ──────────
// 봇에 설정저장하면 서버가 BT_RATCHET_PRESETS / BT_DAILY_TRAIL_PRESETS(세미콜론 목록)에
// 값을 누적 저장하고, 페이지 로드 시 /api/env/params 응답으로 내려준다.
// 여기서는 그 목록을 드롭다운에 ★옵션으로 채우는 일만 한다.
// 드롭다운에 값 하나를 ★옵션으로 추가(중복·빈값 제외)
function btAddPresetOption(selId, val) {
const sel = document.getElementById(selId);
if (!sel) return;
const v = String(val || '').trim();
if (!v) return;
for (const opt of sel.options) { if (opt.value === v) return; }
const o = document.createElement('option');
o.value = v; o.textContent = '★ ' + v;
sel.appendChild(o);
}
// 세미콜론 구분 목록 문자열을 드롭다운에 일괄 채움
function btFillPresetOptions(selId, listStr) {
if (!listStr) return;
for (const v of String(listStr).split(';')) {
btAddPresetOption(selId, v);
}
}
// 돌파 손절모드 색상 — sl_mode=fixed vs atr 배타. atr ON 이면 고정 손절(%) 빨강(무시), atr OFF 이면 ATR 필드 빨강.
function boSyncSlModeColors() {
const modeEl = $('bo_sl_mode');
if (!modeEl) return;
const slEl = $('bo_sl');
const atrIds = ['bo_atr_period', 'bo_atr_sl_mult', 'bo_atr_sl_min', 'bo_atr_sl_max'];
const atrOn = String(modeEl.value || 'fixed').toLowerCase() === 'atr';
const RED = '#f85149';
const paint = (el, inactive) => {
if (!el) return;
if (inactive) {
el.style.setProperty('border-color', RED, 'important');
el.style.setProperty('background-color', 'rgba(248,81,73,0.12)', 'important');
el.style.setProperty('box-shadow', '0 0 0 1px ' + RED, 'important');
} else {
el.style.removeProperty('border-color');
el.style.removeProperty('background-color');
el.style.removeProperty('box-shadow');
}
};
paint(slEl, atrOn);
atrIds.forEach(id => paint($(id), !atrOn));
}
// 래칫/어깨컷 입력칸 색상 토글 — 청산 엔진은 둘이 배타(if/else)다.
// 래칫 값이 있으면 어깨컷(어깨발동·어깨폭)은 실행 안 됨 → 어깨컷 칸을 빨강(무시됨 표시).
// 래칫이 비면 래칫 칸을 빨강(OFF), 어깨컷은 원래색(이때 어깨컷이 1순위 트레일).
function tlSyncRatchetShoulderColors() {
const r = $('tl_ratchet');
if (!r) return;
const smin = $('tl_smin');
const scut = $('tl_scut');
const ratchetOn = !!(r.value && String(r.value).trim());
const RED = '#f85149';
// CSS .form-control 가 border 를 !important 로 강제하므로 인라인도 important 로 줘야 이김
const paint = (el, on) => {
if (!el) return;
if (on) {
el.style.setProperty('border-color', RED, 'important');
el.style.setProperty('background-color', 'rgba(248,81,73,0.12)', 'important');
el.style.setProperty('box-shadow', '0 0 0 1px ' + RED, 'important');
} else {
el.style.removeProperty('border-color');
el.style.removeProperty('background-color');
el.style.removeProperty('box-shadow');
}
el.title = on
? (el === r
? '래칫 OFF — 현재 어깨컷이 1순위 트레일로 동작'
: '래칫 ON — 이 어깨컷 값은 무시됨(래칫이 1순위 트레일)')
: (el.getAttribute('data-orig-title') || el.title);
};
// 래칫 ON → 어깨컷 빨강 / 래칫 OFF → 래칫 빨강
paint(smin, ratchetOn);
paint(scut, ratchetOn);
paint(r, !ratchetOn);
}
function fillMomentumFormFromApi(m) {
if (!m || typeof m !== 'object') return;
const set = (id, val) => { if (val !== null && val !== undefined && $(id)) $(id).value = val; };
set('mom_rsi_min', m.mom_rsi_min);
set('mom_rsi_max', m.mom_rsi_max);
set('mom_vol_mult', m.mom_vol_mult);
set('mom_vol_win', m.mom_vol_win);
set('mom_e_min_chg', m.e_min_chg_pct);
set('mom_time_end_buy', m.mom_time_end_hm);
set('mom_time_start', m.mom_time_start_hm);
if (m.eod_enabled !== undefined && $('mom_eod_enabled')) {
$('mom_eod_enabled').checked = !!m.eod_enabled;
}
if (m.backtest_skip_pre_subscribe !== undefined && $('mom_skip_pre_sub')) {
$('mom_skip_pre_sub').checked = !!m.backtest_skip_pre_subscribe;
}
if (m.eod_hm != null) set('mom_eod_hm', m.eod_hm);
set('mom_sl', m.sl_pct);
set('mom_tp', m.tp_pct);
set('mom_tp_max', m.tp_max_pct);
set('mom_smin', m.shoulder_min_high);
set('mom_scut', m.shoulder_cut_pct);
if (m.trail_pct != null) set('mom_trail', m.trail_pct);
if (m.trail_arm_pct != null) set('mom_trail_arm', m.trail_arm_pct);
if (m.max_hold_bars != null) set('mom_max_hold', m.max_hold_bars);
if ($('mom_ratchet')) $('mom_ratchet').value = (m.ratchet_tiers != null ? String(m.ratchet_tiers) : '');
set('mom_cooldown', m.cooldown_min);
set('mom_max_daily', m.max_daily);
set('mom_slot_money', m.slot_money);
set('mom_slots', m.mom_slots);
if (m.total_budget_krw != null) set('mom_total_budget', m.total_budget_krw);
const momPw = $('mom_portfolio_warn');
if (momPw) {
if (m.portfolio_ui_warning) {
momPw.textContent = '⚠️ ' + m.portfolio_ui_warning;
momPw.style.display = 'block';
} else {
momPw.style.display = 'none';
momPw.textContent = '';
}
}
set('mom_high_chase', m.high_chase_thr);
set('mom_max_daily_chg', m.max_daily_chg);
set('mom_min_price', m.min_price);
set('mom_max_loss_krw', m.max_loss_krw);
set('mom_min_margin', m.min_margin);
set('mom_max_from_open', m.mom_max_from_open_pct);
set('mom_min_from_open', m.mom_min_from_open_pct);
if ($('mom_pat_breakout') && m.pattern_breakout !== undefined) {
$('mom_pat_breakout').checked = !!m.pattern_breakout;
}
if ($('mom_pat_pullback') && m.pattern_pullback !== undefined) {
$('mom_pat_pullback').checked = !!m.pattern_pullback;
}
set('mom_chase_lookback', m.chase_lookback_min);
set('mom_pullback_lookback', m.pullback_lookback_min);
set('mom_pullback_min', m.pullback_min_pct);
set('mom_pullback_max', m.pullback_max_pct);
set('mom_setup_vol', m.setup_vol_max_mult);
set('mom_setup_bear', m.setup_bear_bars_min);
if (m.use_defense_filters !== undefined && $('mom_use_defense')) {
$('mom_use_defense').checked = !!m.use_defense_filters;
}
if ($('mom_use_high_chase_f') && m.use_high_chase_filter !== undefined) {
$('mom_use_high_chase_f').checked = !!m.use_high_chase_filter;
}
if ($('mom_use_daily_range') && m.use_daily_range_filter !== undefined) {
$('mom_use_daily_range').checked = !!m.use_daily_range_filter;
}
if ($('mom_use_ema_f') && m.use_ema_filter !== undefined) {
$('mom_use_ema_f').checked = !!m.use_ema_filter;
}
if ($('mom_use_rsi_max_f') && m.use_rsi_max_filter !== undefined) {
$('mom_use_rsi_max_f').checked = !!m.use_rsi_max_filter;
}
if (m.ema_fast_period != null) set('mom_ema_fast', m.ema_fast_period);
if (m.ema_slow_period != null) set('mom_ema_slow', m.ema_slow_period);
// 당일 누적손익 다단 트레일(꼬리와 동일) 현재값·방식·프리셋 복원
if (m.daily_profit_enabled !== undefined && $('mom_daily_profit_enabled')) {
$('mom_daily_profit_enabled').checked = !!m.daily_profit_enabled;
}
if (m.daily_risk_budget_enabled !== undefined && $('mom_daily_risk_budget_enabled')) {
$('mom_daily_risk_budget_enabled').checked = !!m.daily_risk_budget_enabled;
}
if ($('mom_daily_trail_tiers')) {
$('mom_daily_trail_tiers').value = (m.daily_trail_tiers != null ? String(m.daily_trail_tiers) : '');
}
if (m.daily_profit_mode && $('mom_daily_profit_mode')) {
$('mom_daily_profit_mode').value = m.daily_profit_mode;
}
btFillPresetOptions('mom_daily_trail_preset', m.daily_trail_presets);
// 체크값 = 모멘텀 실효값(전용 우선·없으면 글로벌 상속). 밑에 글로벌/실매 출처를 표시.
const filterNote = (en, glob, explicit) => {
const onoff = v => (v ? 'ON' : 'OFF');
const src = explicit
? '모멘텀 전용 설정값'
: `글로벌 상속(전용 미설정)`;
// 실매도 동일 규칙(orderbook_filter.orderbook_filter_enabled) → 실매 적용값 = 실효값
return `글로벌 ${onoff(glob)} · 실매 ${onoff(en)} · ${src}`;
};
if (m.ob_filter_enabled !== undefined && $('mom_ob_filter')) {
$('mom_ob_filter').checked = !!m.ob_filter_enabled;
if ($('mom_ob_filter_note')) {
$('mom_ob_filter_note').textContent = filterNote(
!!m.ob_filter_enabled, !!m.ob_global_enabled, !!m.ob_strategy_explicit,
);
}
}
if (m.pg_filter_enabled !== undefined && $('mom_pg_filter')) {
$('mom_pg_filter').checked = !!m.pg_filter_enabled;
if ($('mom_pg_filter_note')) {
$('mom_pg_filter_note').textContent = filterNote(
!!m.pg_filter_enabled, !!m.pg_global_enabled, !!m.pg_strategy_explicit,
);
}
}
if (m.max_spread_pct != null) set('mom_max_spread_pct', m.max_spread_pct);
}
function loadMomentumSearchResults() {
showSpinner(true);
fetch('/api/backtest/momentum/search_results?top=30')
.then(r => r.json())
.then(d => {
showSpinner(false);
if (d.error) { alert('❌ ' + d.error); return; }
momRenderParamSearch(d.top || [], d.meta || {});
})
.catch(err => { showSpinner(false); alert('오류: ' + err); });
}
function momApplySearchResult(rank, saveDb) {
showSpinner(true);
fetch('/api/backtest/momentum/apply_search', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ rank, save_db: !!saveDb }),
})
.then(r => r.json())
.then(d => {
showSpinner(false);
if (d.error) { alert('❌ ' + d.error); return; }
if (d.ui) fillMomentumFormFromApi(d.ui);
const m = d.metrics || {};
const msg = saveDb
? `${rank}위 → 폼 반영 + DB 저장 (env_id: ${d.env_id || '-'})\n손익 ${fmtKrw(m.total_pnl)} · 승률 ${m.win_rate}% · PF ${m.pf} · ${m.total_trades}`
: `${rank}위 → 폼만 반영 (DB 미저장)\n손익 ${fmtKrw(m.total_pnl)} · 승률 ${m.win_rate}% · PF ${m.pf}`;
alert(msg);
})
.catch(err => { showSpinner(false); alert('오류: ' + err); });
}
function momRenderParamSearch(top, meta) {
window._mom_last_top = top || [];
const area = $('mom_search_result');
if (area) area.style.display = '';
const labelMap = {
mom_rsi_min: 'RSI min',
mom_rsi_max: 'RSI max',
mom_vol_mult: '거래량×',
e_min_chg_pct: '전일종가+%',
mom_vol_win: '관찰봉',
sl_pct: '손절%',
tp_pct: '익절%',
tp_max_pct: '익절상한%',
shoulder_min_high: '어깨발동',
shoulder_cut_pct: '어깨컷',
trail_trigger: '트레일무장%',
trail_stop: '트레일%',
mom_max_from_open_pct: '시가+과열',
};
const pctKeys = new Set([
'sl_pct', 'tp_pct', 'tp_max_pct', 'shoulder_min_high', 'shoulder_cut_pct',
'trail_trigger', 'trail_stop', 'trail_pct', 'trail_arm_pct',
]);
const fixedLabel = (k) => labelMap[k] || k;
const keys = top && top.length ? Object.keys(top[0].params || {}) : [];
const thead = $('mom_search_thead');
if (thead) {
thead.innerHTML = '<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),
daily_risk_budget_enabled: !!($('tl_daily_risk_budget_enabled')?.checked),
max_hold_bars: 0,
trail_pct: parseFloat($('tl_trail')?.value || '0'),
trail_arm_pct: parseFloat($('tl_trail_arm')?.value || '0'),
backtest_use_tick_db: $('tl_use_tick_db')?.checked !== false,
backtest_tick_fallback_ohlc: !!$('tl_tick_fallback_ohlc')?.checked,
pattern_hammer: $('tl_pat_hammer')?.checked !== false,
pattern_pin: !!$('tl_pat_pin')?.checked,
pattern_engulfing: !!$('tl_pat_engulfing')?.checked,
pattern_piercing: !!$('tl_pat_piercing')?.checked,
pattern_harami: !!$('tl_pat_harami')?.checked,
pattern_doji: !!$('tl_pat_doji')?.checked,
pattern_morning_star: !!$('tl_pat_morning_star')?.checked,
ob_filter: !!($('tl_ob_filter')?.checked),
pg_filter: !!($('tl_pg_filter')?.checked),
eod_enabled: !!($('tl_eod_enabled')?.checked),
eod_hm: ($('tl_eod_hm') && $('tl_eod_hm').value.trim()) || '15:25',
};
if (!confirm(`💾 꼬리잡기 봇(config_short TAIL_*)에 저장합니까?\n\n` +
`1회투자: ${body.slot_money}원 | 동시보유: ${body.max_stocks}종 | 총운용한도: ${body.total_budget_krw || body.max_stocks * body.slot_money}원 | ` +
`낙폭: ${body.min_drop_rate}% | 회복: ${body.min_recovery_ratio}% | 꼬리/몸통: ${body.tail_ratio_min} | 꼬리최소: ${body.tail_pct_min}%\n` +
`손절: ${body.sl_pct}% | 익절: ${body.tp_pct}% | 어깨컷: ${body.shoulder_cut_pct}% | 3분최대회복: ${body.max_rec_3m}% | 고점추격방지: ${body.high_chase_thr}%\n` +
`최소가격: ${body.min_price}원 | 최대급등: ${body.max_daily_change}% | MA20이격: ${body.ma20_max_above}% | 최대손실: ${body.max_loss_krw}\n` +
`ATR손절: ${body.stop_atr_mult}배 | ATR익절: ${body.target_atr_mult}배 | ` +
`캡 SL ${body.atr_sl_min_pct}~${body.atr_sl_max_pct}% TP ${body.atr_tp_min_pct}~${body.atr_tp_max_pct}%\n` +
`래칫: ${body.ratchet_tiers || '(OFF·단일어깨)'}\n` +
`다단트레일: ${body.daily_profit_enabled ? 'ON' : 'OFF'} | 리스크버짓(B): ${body.daily_risk_budget_enabled ? 'ON' : 'OFF'} | 다단규칙: ${body.daily_trail_tiers || '(미설정)'}\n\n` +
`저장: config_short (SHORT·tail_engine·tail_param_search 와 동일 키)`)) return;
fetch('/api/backtest/tail/save_config', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body),
}).then(r => r.json()).then(d => {
if (d.error) { alert('❌ ' + d.error); return; }
// 서버가 DB(env)에 프리셋을 누적 저장했다. 재조회 없이 즉시 드롭다운에도 반영
btAddPresetOption('tl_ratchet_preset', $('tl_ratchet') && $('tl_ratchet').value);
btAddPresetOption('tl_daily_trail_preset', $('tl_daily_trail_tiers') && $('tl_daily_trail_tiers').value);
const tbl = d.saved_by_table ? Object.entries(d.saved_by_table).map(([k,v]) => k + ': ' + v.length + '키').join(', ') : '';
alert(`✅ 저장 완료 (env_id: ${d.env_id})\n${tbl}\n키: ${d.saved_keys?.join(', ')}`);
}).catch(e => alert('오류: ' + e));
}
// 유니버스 배지 문구 (저장 이력 / 시뮬 / 전종목)
function isHistoryUniverseSource(src) {
return String(src || '').startsWith('history');
}
function univHistorySourceParam(selectId) {
const el = $(selectId);
const v = (el && el.value) ? String(el.value).trim().toLowerCase() : 'kiwoom';
return (v === 'ls') ? 'ls' : 'kiwoom';
}
function formatUniverseLabel(p) {
const histSrc = isHistoryUniverseSource(p.universe_source);
if (histSrc && (p.universe_history_slots || 0) > 0) {
const strictTag = (p.universe_timing === 'strict' || String(p.universe_source || '').includes('strict'))
? '·strict' : '';
const lsTag = String(p.universe_source || '').includes('history_ls') ? '·LS' : '';
return `유니버스: 저장 이력 ${p.universe_history_slots}슬롯${lsTag}${strictTag}`;
}
if (p.universe_source === 'sim') return '유니버스: 시뮬레이션';
if (p.universe_source === 'all') return '유니버스: 전체 ws_candles 종목';
return '유니버스: 시뮬레이션(폴백)';
}
/** env 타임라인 쿼리값 — 체크박스 없으면 0(OFF 기본). */
function envTimelineParam(checkboxId) {
return $(checkboxId)?.checked ? 1 : 0;
}
function runBacktest() {
const params = {
start: $('bt_start').value,
end: $('bt_end').value,
rsi_period: $('bt_rsi_period').value,
rsi_oversold: $('bt_rsi_oversold').value,
rsi_overbought: $('bt_rsi_overbought').value,
sl_pct: $('bt_sl').value,
tp_pct: $('bt_tp').value,
tp_max_pct: $('bt_tp_max').value,
drop_rate: $('bt_drop').value,
slot_money: $('bt_slot').value,
cooldown_min: $('bt_cooldown').value,
vol_mult: $('bt_vol_mult').value,
shoulder_min_high: $('bt_smin').value,
shoulder_cut_pct: $('bt_scut').value,
time_start: $('bt_time_start').value,
time_end: $('bt_time_end').value,
max_daily: $('bt_max_daily').value,
high_chase_thr: $('bt_high_chase').value,
max_daily_chg: $('bt_max_daily_chg').value,
min_price: $('bt_min_price').value,
max_loss_krw: $('bt_max_loss_krw').value,
min_margin: $('bt_min_margin').value,
use_defense_filters: $('bt_use_defense')?.checked ? 1 : 0,
use_macd_cross: $('bt_use_macd')?.checked ? 1 : 0,
skip_hts_scan_dupes: $('bt_skip_hts_dupes')?.checked ? 1 : 0,
require_reversal_candle: $('bt_require_reversal')?.checked ? 1 : 0,
max_stocks: $('bt_max_stocks')?.value,
total_budget_krw: $('bt_total_budget')?.value,
eod_enabled: $('bt_eod_enabled')?.checked ? 1 : 0,
eod_hm: ($('bt_eod_hm') && $('bt_eod_hm').value.trim()) || '15:25',
env_timeline: envTimelineParam('bt_env_timeline'),
};
params.universe = $('bt_use_univ_history')?.checked ? 'history' : 'sim';
params.universe_history_source = univHistorySourceParam('bt_univ_history_source');
params.candle_source = $('bt_candle_source')?.value || '';
params.tick_source = $('bt_tick_source')?.value || '';
params.ob_source = $('bt_ob_source')?.value || '';
const qs = new URLSearchParams(params).toString();
showSpinner(true);
fetch('/api/backtest/scalping?' + qs)
.then(r => r.json())
.then(d => { showSpinner(false); renderBacktest(d); })
.catch(err => { showSpinner(false); alert('오류: ' + err); });
}
function renderBacktest(d) {
const s = d.summary;
const p = d.params;
$('bt_params_bar').style.display = '';
const universeLabel = formatUniverseLabel(p);
const tr = (v) => (typeof v === 'number' ? Number(v).toFixed(1) : v);
const btSummaryLine =
`<span class="badge ${isHistoryUniverseSource(p.universe_source) ? 'badge-info' : 'badge-secondary'}" title="후보 이력(키움/LS)">${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}) &lt;${p.rsi_oversold} / &gt;${p.rsi_overbought ?? 75} 과열차단 | ` +
`손절-${tr(p.sl_pct)}% 익절+${tr(p.effective_tp_pct ?? p.tp_pct)}%(상한${tr(p.tp_max_pct)}%) | ` +
`낙폭≥${tr(p.drop_rate)}% | 쿨다운${p.cooldown_min}분 | ` +
`거래량${tr(p.vol_mult)}배 | 어깨(${tr(p.shoulder_min_high)}%발동/${tr(p.shoulder_cut_pct)}%하락) | ` +
`${p.time_window} | 일${p.max_daily}회 | ` +
`종목수 ${p.codes_analyzed}`;
$('bt_params_bar').innerHTML = btSummaryLine;
const 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',
universe_history_source: univHistorySourceParam('tl_univ_history_source'),
candle_source: $('tl_candle_source')?.value || '',
tick_source: $('tl_tick_source')?.value || '',
ob_source: $('tl_ob_source')?.value || '',
entry_mode: ($('tl_entry_mode') && $('tl_entry_mode').value) || 'limit_atr',
limit_atr_mult: $('tl_limit_atr_mult')?.value || '1.5',
limit_anchor: ($('tl_limit_anchor') && $('tl_limit_anchor').value) || 'signal_low',
limit_valid_bars: $('tl_limit_valid_bars')?.value || '1',
limit_fill_slip_pct: $('tl_limit_fill_slip')?.value || '0',
skip_hts_scan_dupes: $('tl_skip_hts_dupes')?.checked !== false ? '1' : '0',
use_intraday_drop: $('tl_use_intraday_drop')?.checked ? '1' : '0',
use_ma20_filter: $('tl_use_ma20_filter')?.checked ? '1' : '0',
use_rsi_filter: $('tl_use_rsi_filter')?.checked !== false ? '1' : '0',
use_daily_range_filter: $('tl_use_daily_range')?.checked !== false ? '1' : '0',
use_high_chase_filter: $('tl_use_high_chase_f')?.checked !== false ? '1' : '0',
bar_chg_min_pct: $('tl_bar_chg_min')?.value || '-10',
bar_chg_max_pct: $('tl_bar_chg_max')?.value || '-1.5',
tail_vol_mult: $('tl_tail_vol_mult')?.value || '0',
tail_vol_win: $('tl_tail_vol_win')?.value || '5',
ratchet_tiers: ($('tl_ratchet') && $('tl_ratchet').value.trim()) || '',
max_hold_bars: '0',
trail_pct: $('tl_trail')?.value || '0',
trail_arm_pct: $('tl_trail_arm')?.value || '0',
backtest_use_tick_db: $('tl_use_tick_db')?.checked !== false ? '1' : '0',
backtest_tick_fallback_ohlc: $('tl_tick_fallback_ohlc')?.checked ? '1' : '0',
pattern_hammer: $('tl_pat_hammer')?.checked !== false ? '1' : '0',
pattern_pin: $('tl_pat_pin')?.checked ? '1' : '0',
pattern_engulfing: $('tl_pat_engulfing')?.checked ? '1' : '0',
pattern_piercing: $('tl_pat_piercing')?.checked ? '1' : '0',
pattern_harami: $('tl_pat_harami')?.checked ? '1' : '0',
pattern_doji: $('tl_pat_doji')?.checked ? '1' : '0',
pattern_morning_star: $('tl_pat_morning_star')?.checked ? '1' : '0',
ob_filter: $('tl_ob_filter')?.checked ? 1 : 0,
pg_filter: $('tl_pg_filter')?.checked ? 1 : 0,
max_spread_pct: $('tl_max_spread_pct')?.value,
daily_trail_tiers: ($('tl_daily_trail_tiers') && $('tl_daily_trail_tiers').value.trim()) || '',
daily_trail_drop_pct: $('tl_daily_trail_drop')?.value || '0',
daily_trail_arm_krw: $('tl_daily_trail_arm')?.value || '0',
daily_profit_mode: ($('tl_daily_profit_mode') && $('tl_daily_profit_mode').value) || 'trailing',
daily_profit_enabled: $('tl_daily_profit_enabled')?.checked ? 1 : 0,
daily_risk_budget_enabled: $('tl_daily_risk_budget_enabled')?.checked ? 1 : 0,
eod_enabled: $('tl_eod_enabled')?.checked ? 1 : 0,
eod_hm: ($('tl_eod_hm') && $('tl_eod_hm').value.trim()) || '15:25',
env_timeline: envTimelineParam('tl_env_timeline'),
};
const qs = new URLSearchParams(params).toString();
showSpinner(true);
fetch('/api/backtest/tail?' + qs)
.then(r => r.json())
.then(d => { showSpinner(false); renderTailBacktest(d); })
.catch(err => { showSpinner(false); alert('오류: ' + err); });
}
// ════════════════════════════════════════════════════════════════
// 전략 백테 CLI 잡 (꼬리·스캘핑·돌파·모멘텀) — 전역 1잡 · 탭 이동 OK · 좀비방지
// ════════════════════════════════════════════════════════════════
const BTJOB_LS_KEY = 'kis_bt_job_id';
const BTJOB_LS_KEY_LEGACY = 'kis_tail_bt_job_id';
let _btJobPollTimer = null;
let _btJobId = localStorage.getItem(BTJOB_LS_KEY)
|| localStorage.getItem(BTJOB_LS_KEY_LEGACY) || '';
let _btJobRenderedId = '';
let _btJobStrategy = '';
const BTJOB_CFG = {
tail: {
tab: 'tail',
label: '꼬리 백테',
startId: 'tl_start', endId: 'tl_end', tfId: 'tl_tf',
univId: 'tl_use_univ_history', univSrcId: 'tl_univ_history_source', tickId: 'tl_use_tick_db',
envId: 'tl_env_timeline', obId: 'tl_ob_filter',
stopBtn: 'tl_btn_stop_bg', tabFill: 'tlbt_tab_fill', tabStatus: 'tlbt_tab_status',
clearPrefix: 'tl_',
universeOn: 'history', universeOff: 'all',
},
scalp: {
tab: 'backtest',
label: '스캘핑 백테',
startId: 'bt_start', endId: 'bt_end', tfId: null,
univId: 'bt_use_univ_history', univSrcId: 'bt_univ_history_source', tickId: null,
envId: 'bt_env_timeline', obId: null,
stopBtn: 'bt_btn_stop_bg', tabFill: 'scalp_bt_tab_fill', tabStatus: 'scalp_bt_tab_status',
clearPrefix: 'b_',
universeOn: 'history', universeOff: 'sim',
},
breakout: {
tab: 'breakout',
label: '돌파 백테',
startId: 'bo_start', endId: 'bo_end', tfId: null,
univId: 'bo_use_univ_history', univSrcId: 'bo_univ_history_source', tickId: null,
envId: 'bo_env_timeline', obId: null,
stopBtn: 'bo_btn_stop_bg', tabFill: 'bo_bt_tab_fill', tabStatus: 'bo_bt_tab_status',
clearPrefix: 'bo_',
universeOn: 'history', universeOff: 'all',
},
momentum: {
tab: 'momentum',
label: '모멘텀 백테',
startId: 'mom_start', endId: 'mom_end', tfId: null,
univId: 'mom_use_univ_history', univSrcId: 'mom_univ_history_source', tickId: null,
envId: 'mom_env_timeline', obId: 'mom_ob_filter',
stopBtn: 'mom_btn_stop_bg', tabFill: 'mom_bt_tab_fill', tabStatus: 'mom_bt_tab_status',
clearPrefix: 'mom_',
universeOn: 'history', universeOff: 'sim',
},
};
function btJobGoTab(strat) {
const s = strat || _btJobStrategy || 'tail';
const cfg = BTJOB_CFG[s] || BTJOB_CFG.tail;
const el = document.querySelector('[data-tab="' + cfg.tab + '"]');
if (el) el.click();
}
function btJobClearUi(strat, msg) {
const cfg = BTJOB_CFG[strat] || BTJOB_CFG.tail;
const map = {
tail: ['tl_total','tl_winrate','tl_pnl','tl_pf','tl_mdd','tl_hold','tl_bot_pct','tl_daily_avg_pct'],
scalp: ['b_total','b_winrate','b_pnl','b_pf','b_mdd','b_hold','b_bot_pct','b_daily_avg_pct'],
breakout: ['bo_total','bo_winrate','bo_pnl','bo_pf','bo_mdd','bo_hold','bo_bot_pct','bo_daily_avg_pct'],
momentum: ['mom_total','mom_winrate','mom_pnl','mom_pf','mom_mdd','mom_hold','mom_bot_pct','mom_daily_avg_pct'],
};
const ids = map[strat] || map.tail;
ids.forEach((id, i) => {
const el = $(id);
if (!el) return;
if (i === 2) el.textContent = '실행중';
else el.textContent = i < 2 ? '…' : '—';
});
const tbody = strat === 'tail' ? $('tl_tbody')
: (strat === 'scalp' ? $('bt_tbody')
: (strat === 'breakout' ? $('bo_tbody') : $('mom_tbody')));
if (tbody) {
tbody.innerHTML = `<tr><td colspan="12" class="text-muted text-center py-3">${msg || '백그라운드 실행 중…'}</td></tr>`;
}
if (cfg.tabStatus && $(cfg.tabStatus)) {
$(cfg.tabStatus).textContent = msg || '실행중…';
}
}
function btJobSetNav(job) {
const bar = $('btjob_nav_bar');
if (!bar) return;
if (!job) {
bar.classList.add('d-none');
return;
}
const strat = job.strategy || _btJobStrategy || 'tail';
_btJobStrategy = strat;
const cfg = BTJOB_CFG[strat] || BTJOB_CFG.tail;
const prog = job.progress || {};
const pct = Number(prog.pct || 0);
const st = job.status || '';
const label = (job.label || cfg.label) +
(st === 'running' ? ' 실행중' : (st === 'done' ? ' 완료' : (st === 'error' ? ' 오류' : '')));
const range = (job.start && job.end) ? ` ${job.start}~${job.end}` : '';
if ($('btjob_nav_label')) $('btjob_nav_label').textContent = label + range;
if ($('btjob_nav_pct')) $('btjob_nav_pct').textContent = `${pct}%`;
const fill = $('btjob_nav_fill');
if (fill) {
fill.style.width = `${Math.min(100, pct)}%`;
fill.classList.toggle('is-done', st === 'done');
fill.classList.toggle('is-error', st === 'error');
}
bar.classList.remove('d-none');
bar.onclick = () => btJobGoTab(strat);
const tabFill = cfg.tabFill && $(cfg.tabFill);
if (tabFill) {
tabFill.style.width = `${Math.min(100, pct)}%`;
tabFill.classList.toggle('is-done', st === 'done');
tabFill.classList.toggle('is-error', st === 'error');
}
const stEl = cfg.tabStatus && $(cfg.tabStatus);
if (stEl) {
const msg = prog.message || '';
stEl.innerHTML =
`<b>${st}</b> ${pct}%` + range +
(msg ? ` · ${msg}` : '') +
(job.log_path ? ` · <code style="font-size:10px">${job.log_path}</code>` : '') +
' · 전역 1잡 · 탭 이동 OK';
}
// 모든 전략 중지 버튼 동기화
Object.values(BTJOB_CFG).forEach(c => {
const b = c.stopBtn && $(c.stopBtn);
if (b) b.disabled = st !== 'running';
});
}
async function btJobLoadResult(jobId, strategy) {
const rr = await fetch('/api/backtest/job/result?job_id=' + encodeURIComponent(jobId));
const rd = await rr.json();
if (!rd.ok) throw new Error(rd.error || '결과 없음');
const strat = strategy || rd.strategy || 'tail';
const payload = rd.data || rd.raw || rd;
// 과거 CLI 버그: 핸들러 500을 ok:true + error 로 저장 → 새로고침마다 alert 재발
const failMsg = (payload && payload.error) || rd.error || '';
if (failMsg) {
const cfg = BTJOB_CFG[strat] || BTJOB_CFG.tail;
const stEl = cfg.tabStatus && $(cfg.tabStatus);
if (stEl) {
stEl.textContent = '실패(구잡): ' + String(failMsg).slice(0, 120) + ' · 다시 실행하세요';
}
_btJobId = '';
localStorage.removeItem(BTJOB_LS_KEY);
localStorage.removeItem(BTJOB_LS_KEY_LEGACY);
btJobSetNav(null);
throw new Error(String(failMsg));
}
if (strat === 'scalp') {
renderBacktest(payload);
} else if (strat === 'breakout') {
renderBreakoutBacktest(payload);
} else if (strat === 'momentum') {
renderMomentumBacktest(payload);
} else {
if (payload && payload.equity) renderTailBacktest(payload);
else if (rd.ok) renderTailBacktestFromCli(rd);
}
}
async function btJobPollOnce() {
const requestedId = _btJobId;
if (!requestedId) return;
try {
const r = await fetch('/api/backtest/job/status?job_id=' + encodeURIComponent(requestedId));
if (requestedId !== _btJobId) return;
const j = await r.json();
if (!j.ok) {
if (r.status === 404 && requestedId === _btJobId) {
_btJobId = '';
localStorage.removeItem(BTJOB_LS_KEY);
localStorage.removeItem(BTJOB_LS_KEY_LEGACY);
btJobSetNav(null);
}
return;
}
const job = j.job || {};
const jid = String(job.job_id || requestedId);
if (jid !== _btJobId) return;
btJobSetNav(job);
const st = job.status;
if (st === 'done' && _btJobRenderedId !== jid) {
try {
await btJobLoadResult(jid, job.strategy);
if (jid !== _btJobId) return;
_btJobRenderedId = jid;
try { btJobGoTab(job.strategy); } catch (e2) {}
} catch (e) {
console.warn('btJobLoadResult', e);
}
}
} catch (e) { /* ignore */ }
}
function btJobStartPolling() {
if (_btJobPollTimer) clearInterval(_btJobPollTimer);
btJobPollOnce();
_btJobPollTimer = setInterval(btJobPollOnce, 2000);
}
function btJobCollectBody(strategy) {
const cfg = BTJOB_CFG[strategy];
if (!cfg) throw new Error('알 수 없는 전략: ' + strategy);
const univOn = !!(cfg.univId && $(cfg.univId)?.checked);
const body = {
strategy,
start: $(cfg.startId)?.value,
end: $(cfg.endId)?.value,
universe: univOn ? cfg.universeOn : cfg.universeOff,
universe_history_source: cfg.univSrcId ? univHistorySourceParam(cfg.univSrcId) : 'kiwoom',
orderbook_filter: (cfg.obId && $(cfg.obId)?.checked) ? 'on' : 'off',
env_timeline: cfg.envId ? envTimelineParam(cfg.envId) : 0,
};
if (cfg.tfId && $(cfg.tfId)) body.timeframe = $(cfg.tfId).value || 3;
if (cfg.tickId) body.backtest_use_tick_db = $(cfg.tickId)?.checked !== false ? 1 : 0;
return body;
}
async function btJobStart(strategy) {
const body = btJobCollectBody(strategy);
if (!body.start || !body.end) { alert('시작·종료일을 입력하세요'); return; }
if (!confirm(
`${BTJOB_CFG[strategy].label} 백그라운드 시작?\n${body.start}~${body.end}\n` +
`파라미터=현재 DB (+기간/유니버스)\n다른 전략 백테와 동시 실행 불가(전역 1잡).\n탭 이동 OK.`
)) return;
try {
const r = await fetch('/api/backtest/job/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const j = await r.json();
if (!j.ok) { alert('❌ ' + (j.error || '시작 실패')); return; }
_btJobId = j.job.job_id;
_btJobStrategy = strategy;
_btJobRenderedId = '';
localStorage.setItem(BTJOB_LS_KEY, _btJobId);
localStorage.removeItem(BTJOB_LS_KEY_LEGACY);
btJobClearUi(strategy, `실행중 ${body.start}~${body.end}`);
btJobSetNav(j.job);
btJobStartPolling();
} catch (e) {
alert('오류: ' + e);
}
}
async function btJobStop() {
if (!_btJobId) return;
if (!confirm('백그라운드 백테를 중지할까요? (프로세스 정리)')) return;
try {
const r = await fetch('/api/backtest/job/stop', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ job_id: _btJobId }),
});
const j = await r.json();
if (!j.ok) { alert(j.error || '중지 실패'); return; }
btJobSetNav(j.job);
} catch (e) { alert('오류: ' + e); }
}
// 하위호환
function runTailBacktestJob() { return btJobStart('tail'); }
function tlBtStop() { return btJobStop(); }
function tlBtGoTab() { return btJobGoTab('tail'); }
function renderTailBacktestFromCli(rd) {
// CLI JSON → 웹 /api/backtest/tail 과 동일 스키마 (equity/daily/reasons/win_trades)
// 없으면 renderTailBacktest 가 equity.map 에서 터져 승패·차트·거래내역이 안 나옴
const summary = rd.summary || (rd.data && rd.data.summary) || {};
const trades = rd.trades || (rd.data && rd.data.trades) || [];
const meta = rd.meta || (rd.data && rd.data.meta) || {};
const pu = rd.params_used || (rd.data && rd.data.params_used) || {};
const sorted = trades.slice().sort((a, b) =>
String(a.exit_time || a.sell_time || '').localeCompare(String(b.exit_time || b.sell_time || ''))
);
let cum = 0;
let peak = 0;
let peakAt = '';
let mdd = 0;
const equity = [];
const dailyMap = {};
const reasons = {};
for (const t of sorted) {
const pnl = Number(t.pnl ?? t.realized_pnl ?? 0) || 0;
cum += pnl;
if (cum > peak) {
peak = cum;
peakAt = String(t.exit_time || t.sell_time || '');
}
const dd = peak - cum;
if (dd > mdd) mdd = dd;
const et = String(t.exit_time || t.sell_time || '').replace(/[-: T]/g, '');
const day = et.length >= 8
? `${et.slice(0, 4)}-${et.slice(4, 6)}-${et.slice(6, 8)}`
: (et || '—');
equity.push({ date: day, cum_pnl: Math.round(cum), pnl: Math.round(pnl) });
dailyMap[day] = (dailyMap[day] || 0) + pnl;
const rk = String(t.reason || t.sell_reason || '기타');
reasons[rk] = (reasons[rk] || 0) + 1;
}
const daily = Object.keys(dailyMap).sort().map(d => ({
date: d,
pnl: Math.round(dailyMap[d]),
}));
const winsN = Number(
summary.win_trades != null ? summary.win_trades
: (summary.wins != null ? summary.wins
: trades.filter(t => Number(t.pnl ?? t.realized_pnl ?? 0) > 0).length)
);
const lossN = Number(
summary.loss_trades != null ? summary.loss_trades
: (summary.losses != null ? summary.losses
: Math.max(0, trades.length - winsN))
);
const fake = {
summary: {
total_trades: summary.total_trades != null ? summary.total_trades : trades.length,
win_trades: winsN,
loss_trades: lossN,
win_rate: summary.win_rate || 0,
total_pnl: summary.total_pnl || 0,
profit_factor: summary.profit_factor != null ? summary.profit_factor : (summary.pf || 0),
max_drawdown: summary.max_drawdown != null ? summary.max_drawdown
: (summary.mdd != null ? summary.mdd : Math.round(mdd)),
avg_hold_min: summary.avg_hold_min || 0,
bot_pct: summary.bot_pct || 0,
daily_avg_pct: summary.daily_avg_pct || 0,
peak_cum_pnl: summary.peak_cum_pnl != null ? summary.peak_cum_pnl : Math.round(peak),
peak_cum_at: summary.peak_cum_at || peakAt,
tick_backtest: summary.tick_backtest || meta.tick_backtest,
backtest_buy_source: summary.backtest_buy_source || meta.backtest_buy_source,
tick_entry_sources: summary.tick_entry_sources || meta.tick_entry_sources,
skip_stats: summary.skip_stats || meta.skip_stats || {},
},
params: {
universe_source: meta.universe_source || rd.universe_source || 'history',
timeframe: rd.timeframe || pu.timeframe || 3,
slot_money: pu.slot_money || 0,
max_stocks: pu.max_stocks || 0,
total_budget_krw: pu.total_budget_krw || 0,
codes_analyzed: meta.codes_analyzed || meta.n_codes
|| (Array.isArray(meta.codes) ? meta.codes.length : (trades.length || 0)),
min_drop_rate: (function () {
const v = Number(pu.min_drop_rate);
if (!Number.isFinite(v)) return pu.min_drop_rate ?? '';
// 엔진 비율(0.055) → UI % (5.5)
return (v > 0 && v <= 1) ? (v * 100) : v;
})(),
min_recovery_ratio: (function () {
const v = Number(pu.min_recovery_ratio);
if (!Number.isFinite(v)) return pu.min_recovery_ratio ?? '';
return (v > 0 && v <= 1) ? (v * 100) : v;
})(),
tail_ratio_min: pu.tail_ratio_min ?? '',
sl_pct: (function () {
const v = Number(pu.sl_pct);
if (!Number.isFinite(v)) return pu.sl_pct ?? '';
return (v > 0 && v <= 1) ? (v * 100) : v;
})(),
tp_pct: (function () {
const v = Number(pu.tp_pct);
if (!Number.isFinite(v)) return pu.tp_pct ?? '';
return (v > 0 && v <= 1) ? (v * 100) : v;
})(),
shoulder_min_high: (function () {
const v = Number(pu.shoulder_min_high ?? pu.shoulder_min_high_pct);
if (!Number.isFinite(v)) return '';
return (v > 0 && v <= 1) ? (v * 100) : v;
})(),
shoulder_cut_pct: (function () {
const v = Number(pu.shoulder_cut_pct);
if (!Number.isFinite(v)) return '';
return (v > 0 && v <= 1) ? (v * 100) : v;
})(),
trail_pct: pu.trail_pct ?? 0,
trail_arm_pct: pu.trail_arm_pct ?? 0,
max_loss_krw: pu.max_loss_krw,
rsi_period: pu.rsi_period ?? '',
rsi_threshold: pu.rsi_threshold ?? '',
cooldown_min: pu.cooldown_min ?? '',
time_window: pu.time_window || (
(pu.time_start != null && pu.time_end != null)
? `${pu.time_start}-${pu.time_end}` : ''
),
max_daily: pu.max_daily ?? '',
entry_mode: pu.entry_mode,
limit_atr_mult: pu.limit_atr_mult,
limit_anchor: pu.limit_anchor,
limit_valid_bars: pu.limit_valid_bars,
limit_fill_slip_pct: pu.limit_fill_slip_pct,
},
equity,
daily,
reasons,
trades,
};
try {
renderTailBacktest(fake);
} catch (e) {
console.warn('renderTailBacktestFromCli', e);
alert(
`백그라운드 완료(화면 렌더 실패)\n거래 ${summary.total_trades} · WR ${summary.win_rate}% · PnL ${summary.total_pnl}\n` +
(rd.result_json || '') + `\n${e}`
);
}
}
// 페이지 로드 시 이전 잡 폴링 재개 (탭 무관)
if (_btJobId) {
setTimeout(() => btJobStartPolling(), 800);
}
function renderTailBacktest(d) {
const s = d.summary;
const p = d.params;
$('tl_params_bar').style.display = '';
const tlUniverseLabel = formatUniverseLabel(p);
const tlSummaryLine =
`<span class="badge ${isHistoryUniverseSource(p.universe_source) ? 'badge-info' : 'badge-secondary'}" title="후보 이력(키움/LS)">${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 ?? s.pf) || 0) >= 999 ? '∞' : ((s.profit_factor ?? s.pf) || 0);
colorPnl($('tl_pf'), ((s.profit_factor ?? s.pf) || 0) - 1);
$('tl_mdd').textContent = '-' + fmtWon(s.max_drawdown ?? s.mdd ?? 0) + '원';
$('tl_hold').textContent = (s.avg_hold_min||0) + '분';
const tlSign = v => (v > 0 ? '+' : '');
$('tl_bot_pct').textContent = tlSign(s.bot_pct || 0) + (s.bot_pct || 0) + '%';
colorPnl($('tl_bot_pct'), s.bot_pct || 0);
$('tl_daily_avg_pct').textContent = tlSign(s.daily_avg_pct || 0) + (s.daily_avg_pct || 0) + '%';
colorPnl($('tl_daily_avg_pct'), s.daily_avg_pct || 0);
if (s.universe_warning) {
$('tl_params_bar').innerHTML += `<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;
const winN = s.win_trades != null ? s.win_trades : (s.wins || 0);
const lossN = s.loss_trades != null ? s.loss_trades : (s.losses || 0);
$('tl_win_label').textContent = `🟢 승 ${winN}건 (${wr}%)`;
$('tl_loss_label').textContent = `🔴 패 ${lossN}건 (${(100-wr).toFixed(1)}%)`;
$('tl_ratio_g').style.width = wr + '%';
$('tl_ratio_r').style.width = (100 - wr) + '%';
}
// 누적 손익 곡선 (백그라운드 CLI 결과는 equity 없을 수 있음 → 빈 배열 허용)
const tlEquity = Array.isArray(d.equity) ? d.equity : [];
lineChart('tl_equity_chart',
tlEquity.map(e => e.date),
tlEquity.map(e => e.cum_pnl),
'가상누적손익', '#3fb950');
// 매도 이유 도넛
const tlReasons = d.reasons || {};
const rKeys = Object.keys(tlReasons);
doughnutChart('tl_reason_chart', rKeys, rKeys.map(k => tlReasons[k]));
// 일별 바
const tlDaily = Array.isArray(d.daily) ? d.daily : [];
barChart('tl_daily_chart',
tlDaily.map(e => String(e.date || '').slice(5)),
tlDaily.map(e => e.pnl));
renderVirtualTrades('tl_tbody', d.trades || [], {
showDebug: true,
showCumulative: true,
totalBudget: Number((d.params && d.params.total_budget_krw) || 0),
});
}
// ────────────────────────────────────────────
// 홀딩 전략
// ────────────────────────────────────────────
let hdCurrentCode = null;
let hdCurrentName = null;
let hdCurrentCfg = {};
function hdLoadStocks() {
fetch('/api/holding/stocks')
.then(r => r.json())
.then(stocks => hdRenderStockList(stocks))
.catch(err => alert('종목 로드 실패: ' + err));
}
function hdRenderStockList(stocks) {
const list = $('hd_stock_list');
list.innerHTML = '';
stocks.forEach(s => {
const candleInfo = s.candle_count > 0
? `<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 &amp; Hold 비교는 백테스트 화면의 추세BT 결과를 참고하세요.'
+ metaHtml;
}
if ((!top || !top.length) && meta && $('hd_search_hint')) {
$('hd_search_hint').innerHTML += '<br><span style="color:#f85149">TOP이 비었습니다. 위 「탐색 최소거래」를 0 또는 1로 낮추거나, 백테스트 기간을 넓혀 보세요.</span>';
}
$('hd_result_area').scrollIntoView({behavior:'smooth'});
}
// ────────────────────────────────────────────
// 운영 설정 탭 (live_config)
// ────────────────────────────────────────────
let _lcTimer = null;
let _lcData = null;
function lcInitDate() {
const el = $('lc_date');
if (!el) return;
if (!el.value) {
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, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
}
function lcRenderStatus(status) {
const box = $('lc_status_cards');
if (!box || !status) return;
const g = status.global || {};
const haltedCls = g.buy_halted ? 'badge-loss' : 'badge-win';
const haltedTxt = g.buy_halted ? '매수중단' : '매수허용';
let html = `
<div class="col-md-4">
<div class="card p-3 stat-card">
<div class="stat-label">총합 봇 실현</div>
<div class="stat-value ${dashPnlCls(g.realized_pnl_krw)}">${dashFmtKrw(g.realized_pnl_krw)}</div>
<div style="font-size:12px;color:var(--muted)">
목표 ${dashFmtKrw(g.target_krw)} / ${Number(g.target_pct || 0).toFixed(2)}%
· 한도 ${dashFmtKrw(g.budget_krw)}
· <span class="${haltedCls}">${haltedTxt}</span>
</div>
</div>
</div>`;
(status.strategies || []).forEach(row => {
if (!row.enabled) return;
const subHalt = row.buy_halted ? 'badge-loss">중단' : 'badge-win">허용';
html += `
<div class="col-md-4 col-lg-3">
<div class="card p-3" style="font-size:13px">
<b>${lcEsc(row.label)}</b> <code style="font-size:11px">${lcEsc(row.strategy_id)}</code>
<div class="${dashPnlCls(row.realized_pnl_krw)}">${dashFmtKrw(row.realized_pnl_krw)}</div>
<div style="color:var(--muted);font-size:11px">
${Number(row.return_pct || 0).toFixed(2)}% · <span class="${subHalt}</span>
</div>
</div>
</div>`;
});
box.innerHTML = html;
}
function lcFieldInput(f) {
const id = 'lc_' + f.key;
const hint = f.hint ? `<div class="lc-field-hint">${lcEsc(f.hint)}</div>` : '';
const tbl = f.table ? `<code class="lc-field-tbl" title="저장 테이블">${lcEsc(f.table)}</code>` : '';
const keyTag = f.key
? `<code class="lc-field-key" title="${lcEsc(f.key)}">${lcEsc(f.key)}</code>`
: '';
const isDanger = !!(f.danger) || /FALLBACK_OHLC/i.test(String(f.key || ''));
const wrapCls = isDanger ? 'lc-field lc-field-danger' : 'lc-field';
if (f.type === 'bool') {
const chk = f.value ? 'checked' : '';
return `<div class="${wrapCls}" 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="${wrapCls}" 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>`;
}
if (f.type === 'select' && Array.isArray(f.choices)) {
const val = f.value != null ? String(f.value) : '';
const opts = f.choices.map(c => {
const cv = (typeof c === 'object') ? String(c.value) : String(c);
const cl = (typeof c === 'object') ? String(c.label != null ? c.label : c.value) : String(c);
const sel = (cv === val) ? ' selected' : '';
return `<option value="${lcEsc(cv)}"${sel}>${lcEsc(cl)}</option>`;
}).join('');
return `<div class="${wrapCls}" title="${lcEsc(f.key || '')}">
<label class="lc-field-label" for="${id}">${lcEsc(f.label)} ${keyTag}</label> ${tbl}
<select class="form-select form-select-sm" data-lc-key="${lcEsc(f.key)}" data-lc-type="text" id="${id}">${opts}</select>
${hint}
</div>`;
}
const step = f.type === 'int' ? '1' : '0.01';
const val = f.value != null ? f.value : '';
return `<div class="${wrapCls}" 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);
}
// ════════════════════════════════════════════════════════════════
// Optuna 웹 잡 (상단 미니바 + Optuna 탭)
// ════════════════════════════════════════════════════════════════
const OPTUNA_LS_KEY = 'kis_optuna_job_id';
let _optunaPollTimer = null;
let _optunaJobId = localStorage.getItem(OPTUNA_LS_KEY) || '';
function optunaGoTab() {
const el = document.querySelector('[data-tab="optuna"]');
if (el) el.click();
}
function optunaOnTabShow() {
optunaLoadDefaults();
optunaRefreshJobs();
if (_optunaJobId) optunaPollOnce();
}
async function optunaLoadDefaults() {
try {
const r = await fetch('/api/optuna/defaults');
const j = await r.json();
if (!j.ok) return;
if ($('opt_start') && !$('opt_start').value) $('opt_start').value = j.start;
if ($('opt_end') && !$('opt_end').value) $('opt_end').value = j.end;
if ($('opt_trials') && !$('opt_trials').dataset.touched) $('opt_trials').value = j.trials;
} catch (e) { /* ignore */ }
}
function optunaSetNav(job) {
const bar = $('optuna_nav_bar');
if (!bar) return;
if (!job) {
bar.classList.add('d-none');
return;
}
const prog = job.progress || {};
const pct = Number(prog.pct || 0);
const st = job.status || '';
const label = (job.label || job.strategy || 'Optuna') +
(st === 'running' ? ' 실행중' : (st === 'done' ? ' 완료' : (st === 'error' ? ' 오류' : '')));
const done = prog.trials_done != null ? prog.trials_done : '?';
const tot = prog.trials_total != null ? prog.trials_total : '?';
$('optuna_nav_label').textContent = `${label} ${done}/${tot}`;
const met = optunaBestMetricsText(prog, job);
$('optuna_nav_pct').textContent = met ? `${pct}% · ${met}` : `${pct}%`;
const fill = $('optuna_nav_fill');
fill.style.width = `${Math.min(100, pct)}%`;
fill.classList.toggle('is-done', st === 'done');
fill.classList.toggle('is-error', st === 'error');
bar.classList.remove('d-none');
}
function optunaFmtNum(v, dig) {
if (v == null || v === '') return '—';
const n = Number(v);
if (!Number.isFinite(n)) return String(v);
if (dig == null) return n.toLocaleString();
return n.toFixed(dig);
}
/** progress.best_* → 사람이 읽는 WR/PnL/PF/MDD (score 숨김) */
function optunaBestMetricsText(prog, job) {
if (!prog || prog.best_pnl == null && prog.best_win_rate == null) return '';
const strat = String((job && (job.strategy || job.label)) || '').toLowerCase();
const isUs = !!((job && job.symbol) || strat.includes('us_momentum') || strat.includes('해외'));
const pnl = Number(prog.best_pnl);
const pnlStr = !Number.isFinite(pnl) ? '—'
: (isUs
? `$${pnl.toLocaleString(undefined, { maximumFractionDigits: 2 })}`
: `${Math.round(pnl).toLocaleString()}`);
const wr = prog.best_win_rate != null ? `${Number(prog.best_win_rate).toFixed(1)}%` : '—';
const pf = prog.best_pf != null ? Number(prog.best_pf).toFixed(2) : '—';
const mdd = prog.best_mdd != null
? (isUs
? `$${Number(prog.best_mdd).toLocaleString(undefined, { maximumFractionDigits: 2 })}`
: `${Math.round(Number(prog.best_mdd)).toLocaleString()}`)
: '—';
const tr = prog.best_trades != null ? `${prog.best_trades}` : '';
const tn = prog.best_trial != null ? `#${prog.best_trial}` : '';
return `best${tn} WR ${wr} · PnL ${pnlStr} · PF ${pf} · MDD ${mdd}` +
(tr ? ` · ${tr}` : '');
}
function optunaRenderCompare(sum) {
const tb = $('opt_compare_tbody');
const vsEl = $('opt_vs_best');
const gatesEl = $('opt_stable_gates');
if (!tb) return;
const rows = (sum && sum.compare_rows) || [];
if (!rows.length) {
tb.innerHTML = '<tr><td colspan="9" class="text-muted">완료 후 표시</td></tr>';
if (vsEl) vsEl.textContent = '—';
if (gatesEl) gatesEl.textContent = '—';
optunaRenderTrailRec(null);
optunaRenderPostprocess(null);
return;
}
tb.innerHTML = rows.map(r => `<tr>
<td>${r.label || r.source}</td>
<td>${r.optuna_trial_number != null ? '#' + r.optuna_trial_number : '—'}</td>
<td class="text-end">${optunaFmtNum(r.total_trades, 0)}</td>
<td class="text-end">${optunaFmtNum(r.win_rate, 1)}</td>
<td class="text-end">${optunaFmtNum(r.pf, 2)}</td>
<td class="text-end ${Number(r.total_pnl) >= 0 ? 'text-pnl-pos' : 'text-pnl-neg'}">${optunaFmtNum(r.total_pnl)}</td>
<td class="text-end">${r.n_losing_days != null ? optunaFmtNum(r.n_losing_days, 0) : '—'}</td>
<td class="text-end ${Number(r.worst_day_pnl) >= 0 ? 'text-pnl-pos' : 'text-pnl-neg'}">${r.worst_day_pnl != null ? optunaFmtNum(r.worst_day_pnl) : '—'}</td>
<td class="text-end">${r.stability_score != null ? optunaFmtNum(r.stability_score, 2) : '—'}</td>
</tr>`).join('');
const vs = sum && sum.vs_best;
if (vsEl) {
if (vs) {
vsEl.innerHTML =
`mode vs 학습1위: Δpnl <b class="${Number(vs.delta_pnl) >= 0 ? 'text-pnl-pos' : 'text-pnl-neg'}">${optunaFmtNum(vs.delta_pnl)}</b>` +
` · Δwr ${optunaFmtNum(vs.delta_wr, 1)}%p · Δpf ${optunaFmtNum(vs.delta_pf, 2)}` +
` <span class="text-muted">(best ${optunaFmtNum(vs.best_pnl)} / mode ${optunaFmtNum(vs.mode_pnl)})</span>`;
} else {
vsEl.textContent = 'vs_best 없음 (mode 실측 전/실패 가능)';
}
}
if (gatesEl) {
const g = sum && sum.stable_gates;
if (g) {
gatesEl.textContent =
`stable 게이트: 손실일≤${g.max_losing_days} · 최악일≥${Number(g.min_worst_day_pnl).toLocaleString()} · ` +
`활성일≥${g.min_active_days} · λ=${g.stability_lambda}` +
(g.score_note ? ` · ${g.score_note}` : '');
} else {
gatesEl.textContent = 'stable 게이트: 구 JSON이거나 미기록 (Optuna 재실행 시 생성)';
}
}
optunaRenderTrailRec(sum);
optunaRenderPostprocess(sum);
}
function optunaRenderTrailRec(sum) {
const body = $('opt_daily_trail_rec_body');
if (!body) return;
const rec = sum && sum.daily_trail_recommend;
if (!rec) {
body.textContent = '완료 후 표시';
return;
}
if (!rec.ok) {
body.innerHTML =
`<span class="text-muted">추천 없음</span> — ${rec.reason || '양수 PnL 앵커 없음'}`;
return;
}
const arm = Number(rec.arm_krw || 0).toLocaleString();
const anchor = Number(rec.anchor_krw || 0).toLocaleString();
body.innerHTML =
`<b>${rec.prefix || ''}</b> · ENABLED=true · MODE=${rec.mode || 'trailing'}` +
`<br>ARM <b>${arm}</b>원 · TIERS <code>${rec.tiers || ''}</code>` +
`<br><span class="text-muted">앵커 ${anchor}원 (mode/median/best×frac) · apply 시에만 DB 기록 · 이후 운영탭에서 끄기/수정 가능</span>`;
}
/** Optuna 후처리 3종 표 — 호가(진입+EXIT익절) · 휩쏘 · 트레일. STOP_OB 합의는 미구현. */
function optunaRenderPostprocess(sum) {
const tb = $('opt_postprocess_tbody');
if (!tb) return;
if (!sum) {
tb.innerHTML = '<tr><td colspan="5" class="text-muted">완료 후 표시</td></tr>';
return;
}
const rows = [];
const ob = sum.orderbook_recommend;
if (!ob) {
rows.push({
kind: '호가 (진입+익절)',
st: '미포함',
nums: '—',
stats: '—',
note: '구 JSON / 미생성',
});
} else if (!ob.ok) {
rows.push({
kind: '호가 (진입+익절)',
st: '실패',
nums: '—',
stats: '—',
note: ob.reason || 'n/a',
});
} else {
const p = ob.params || {};
const rs = ob.recommended_stats || {};
const exitOn = p.exit_ob_enabled ? 'ON' : 'OFF';
rows.push({
kind: '호가 (진입+익절)',
st: 'ok',
nums:
`스프레드≤${p.orderbook_max_spread_pct != null ? p.orderbook_max_spread_pct : '—'}%` +
` · 잔량≥${p.orderbook_min_bid_ask_ratio != null ? p.orderbook_min_bid_ask_ratio : '—'}` +
` · EXIT_OB=${exitOn}` +
(p.exit_ob_enabled
? ` (OR≥${p.exit_ob_ratio_min != null ? p.exit_ob_ratio_min : '—'})`
: ''),
stats:
`모수 ${ob.trade_count != null ? ob.trade_count : '—'}` +
(rs.win_rate != null ? ` · WR ${rs.win_rate}%` : '') +
(rs.pnl != null ? ` · PnL ${Number(rs.pnl).toLocaleString()}` : ''),
note: 'STOP_OB 합의 없음 · apply는 별도 스크립트/수동',
});
}
const ws = sum.whipsaw_recommend;
if (!ws) {
rows.push({
kind: '휩쏘',
st: '미포함',
nums: '—',
stats: '—',
note: '구 JSON / 미생성',
});
} else if (!ws.ok) {
rows.push({
kind: '휩쏘',
st: '실패',
nums: '—',
stats: '—',
note: ws.reason || 'n/a',
});
} else {
const p = ws.params || {};
const rs = ws.recommended_stats || {};
rows.push({
kind: '휩쏘',
st: 'ok',
nums:
`subbar=${p.whipsaw_subbar_sec != null ? p.whipsaw_subbar_sec : '—'}` +
` · lookback=${p.whipsaw_lookback_sec != null ? p.whipsaw_lookback_sec : '—'}` +
` · dip=${p.whipsaw_dip_pct != null ? p.whipsaw_dip_pct : '—'}`,
stats:
(rs.win_rate != null ? `WR ${rs.win_rate}%` : '—') +
(rs.pnl != null ? ` · PnL ${Number(rs.pnl).toLocaleString()}` : ''),
note: 'TAIL/BREAKOUT 등은 DB 적용 차단·표시만 가능',
});
}
const tr = sum.daily_trail_recommend;
if (!tr) {
rows.push({
kind: '다단트레일',
st: '미포함',
nums: '—',
stats: '—',
note: '—',
});
} else if (!tr.ok) {
rows.push({
kind: '다단트레일',
st: '실패',
nums: '—',
stats: '—',
note: tr.reason || '양수 PnL 앵커 없음',
});
} else {
rows.push({
kind: '다단트레일',
st: 'ok',
nums: `ARM ${Number(tr.arm_krw || 0).toLocaleString()} · ${tr.tiers || ''}`,
stats: tr.anchor_krw != null ? `앵커 ${Number(tr.anchor_krw).toLocaleString()}` : '—',
note: 'Optuna DB 적용 시에만 반영',
});
}
rows.push({
kind: '손절호가 STOP_OB',
st: '미구현',
nums: '—',
stats: '—',
note: '후처리 합의·패치 없음 (DB ON은 수동)',
});
tb.innerHTML = rows.map(r => `<tr>
<td>${r.kind}</td>
<td>${r.st}</td>
<td style="font-size:11px">${r.nums}</td>
<td class="text-end" style="font-size:11px">${r.stats}</td>
<td class="text-muted" style="font-size:11px">${r.note}</td>
</tr>`).join('');
}
function optunaRenderTop5(sum) {
const tb = $('opt_top5_tbody');
if (!tb) return;
const rows = (sum && sum.top5_gated) || [];
if (!rows.length) {
tb.innerHTML = '<tr><td colspan="7" class="text-muted">gated 후보 없음 / 미완료</td></tr>';
} else {
tb.innerHTML = rows.map(r => {
const rank = r.rank || 1;
return `<tr>
<td>${rank}</td>
<td>#${r.optuna_trial_number ?? '—'}</td>
<td class="text-end">${optunaFmtNum(r.total_trades, 0)}</td>
<td class="text-end">${optunaFmtNum(r.win_rate, 1)}%</td>
<td class="text-end">${optunaFmtNum(r.pf, 2)}</td>
<td class="text-end ${Number(r.total_pnl) >= 0 ? 'text-pnl-pos' : 'text-pnl-neg'}">${optunaFmtNum(r.total_pnl)}</td>
<td>
<button class="btn btn-sm btn-outline-secondary py-0" onclick="optunaHighlightGated(${rank})">보기</button>
<button class="btn btn-sm btn-outline-success py-0" onclick="optunaApply('gated',${rank})">적용</button>
</td>
</tr>`;
}).join('');
}
optunaRenderTop5Stable(sum);
}
function optunaRenderTop5Stable(sum) {
const tb = $('opt_top5_stable_tbody');
if (!tb) return;
const rows = (sum && sum.top5_stable) || [];
if (!rows.length) {
tb.innerHTML = '<tr><td colspan="9" class="text-muted">stable 후보 없음 (구 JSON은 재실행 필요 / 게이트 미충족)</td></tr>';
return;
}
tb.innerHTML = rows.map(r => {
const rank = r.rank || 1;
return `<tr>
<td>${rank}</td>
<td>#${r.optuna_trial_number ?? '—'}</td>
<td class="text-end">${optunaFmtNum(r.total_trades, 0)}</td>
<td class="text-end">${optunaFmtNum(r.win_rate, 1)}%</td>
<td class="text-end ${Number(r.total_pnl) >= 0 ? 'text-pnl-pos' : 'text-pnl-neg'}">${optunaFmtNum(r.total_pnl)}</td>
<td class="text-end">${r.n_losing_days != null ? optunaFmtNum(r.n_losing_days, 0) : '—'}</td>
<td class="text-end ${Number(r.worst_day_pnl) >= 0 ? 'text-pnl-pos' : 'text-pnl-neg'}">${r.worst_day_pnl != null ? optunaFmtNum(r.worst_day_pnl) : '—'}</td>
<td class="text-end">${r.stability_score != null ? optunaFmtNum(r.stability_score, 2) : '—'}</td>
<td>
<button class="btn btn-sm btn-outline-secondary py-0" onclick="optunaShowCandidate('stable',${rank})">보기</button>
<button class="btn btn-sm btn-outline-info py-0" onclick="optunaApply('stable',${rank})">적용</button>
</td>
</tr>`;
}).join('');
}
function optunaHighlightGated(rank) {
optunaShowCandidate('gated', rank || 1);
}
async function optunaShowCandidate(source, rank) {
if (!_optunaJobId) {
alert('선택된 job 없음 — 최근 잡에서「보기」로 job을 먼저 고르세요');
return;
}
const src = source || 'gated';
const rk = rank || 1;
const title = $('opt_detail_title');
const met = $('opt_detail_metrics');
const pre = $('opt_detail_params');
if (title) title.textContent = `로딩 ${src} #${rk}`;
if (met) met.textContent = '불러오는 중…';
try {
const qs = new URLSearchParams({
job_id: _optunaJobId,
source: src,
rank: String(rk),
});
const r = await fetch('/api/optuna/candidate?' + qs.toString());
const j = await r.json();
if (!j.ok) {
if (met) met.textContent = '❌ ' + (j.error || '실패');
if (pre) pre.textContent = '—';
return;
}
const m = j.metrics || {};
if (title) {
title.textContent =
`${j.strategy || ''} · ${m.label || src} · trial ${m.optuna_trial_number != null ? '#' + m.optuna_trial_number : '—'}`;
}
if (met) {
const daily = m.daily_pnl && typeof m.daily_pnl === 'object'
? Object.keys(m.daily_pnl).sort().map(d => `${d}:${Number(m.daily_pnl[d]).toLocaleString()}`).join(' · ')
: '';
met.innerHTML =
`거래 <b>${optunaFmtNum(m.total_trades, 0)}</b> · ` +
`승률 <b>${optunaFmtNum(m.win_rate, 1)}%</b> · ` +
`PF <b>${optunaFmtNum(m.pf, 2)}</b> · ` +
`PnL <b class="${Number(m.total_pnl) >= 0 ? 'text-pnl-pos' : 'text-pnl-neg'}">${optunaFmtNum(m.total_pnl)}</b>` +
(m.stability_score != null
? ` · 안정 <b>${optunaFmtNum(m.stability_score, 2)}</b> (손실일 ${optunaFmtNum(m.n_losing_days, 0)} · 최악 ${optunaFmtNum(m.worst_day_pnl)})`
: '') +
` · 파라미터 ${j.params_count || 0}` +
(j.start && j.end ? ` · 기간 ${j.start}~${j.end}` : '') +
(daily ? `<div class="mt-1 text-muted">일별 PnL: ${daily}</div>` : '') +
` <button class="btn btn-sm btn-outline-primary py-0 ms-2" onclick="optunaFillFormFromCandidate('${src}',${rk})">폼에 반영</button>` +
` <button class="btn btn-sm btn-outline-success py-0 ms-1" onclick="optunaApply('${src}',${rk})">이 후보 DB적용</button>`;
}
if (pre) {
pre.textContent = JSON.stringify(j.params_preview || j.params_full || {}, null, 2);
}
// 폼 반영용 캐시
window._optunaCandidateCache = {
strategy: j.strategy,
start: j.start,
end: j.end,
params: j.params_full || j.params_preview || {},
};
// Top5 행 하이라이트
document.querySelectorAll('#opt_top5_tbody tr').forEach((tr, i) => {
tr.style.outline = (i + 1 === rk) ? '1px solid var(--accent)' : '';
});
const card = $('opt_detail_card');
if (card) card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} catch (e) {
if (met) met.textContent = '오류: ' + e;
}
}
function optunaRenderJob(job) {
if (!job) return;
const prog = job.progress || {};
const pct = Number(prog.pct || 0);
const fill = $('opt_tab_fill');
if (fill) {
fill.style.width = `${Math.min(100, pct)}%`;
fill.classList.toggle('is-done', job.status === 'done');
fill.classList.toggle('is-error', job.status === 'error');
}
if ($('opt_st_status')) $('opt_st_status').textContent = job.status || '—';
if ($('opt_st_job')) $('opt_st_job').textContent = job.job_id || '—';
if ($('opt_st_study')) $('opt_st_study').textContent = job.study_name || '—';
if ($('opt_st_trials')) {
const met = optunaBestMetricsText(prog, job);
$('opt_st_trials').textContent =
`${prog.trials_done ?? '—'} / ${prog.trials_total ?? job.trials ?? '—'} (${pct}%)` +
(job.current_strategy ? ` · now=${job.current_strategy}` : '') +
(met ? ` · ${met}` : '');
}
if ($('opt_st_log')) $('opt_st_log').textContent = job.log_path || '—';
if ($('opt_st_json')) $('opt_st_json').textContent = job.result_json || '—';
const sum = job.result_summary;
if ($('opt_st_top')) {
if (sum && sum.top) {
const t = sum.top;
$('opt_st_top').innerHTML =
`상위(${t.source}): trial #${t.optuna_trial_number} · ` +
`거래 ${t.total_trades} · WR ${Number(t.win_rate || 0).toFixed(1)}% · ` +
`PF ${Number(t.pf || 0).toFixed(2)} · PnL ${Number(t.total_pnl || 0).toLocaleString()}` +
` · gated ${sum.n_gated}/${sum.n_all}` +
(sum.n_stable != null ? ` · stable ${sum.n_stable}` : '');
} else {
$('opt_st_top').textContent = job.error || '결과 요약 없음';
}
}
if ($('opt_log_tail')) $('opt_log_tail').textContent = job.log_tail || '';
if ($('opt_briefing')) {
$('opt_briefing').textContent = job.briefing_preview || '—';
}
const running = job.status === 'running';
const done = job.status === 'done';
if ($('opt_btn_start')) $('opt_btn_start').disabled = running;
if ($('opt_btn_stop')) $('opt_btn_stop').disabled = !running;
const applyBox = $('opt_apply_box');
if (applyBox) {
applyBox.classList.toggle('d-none', !done);
const canGate = !!(sum && sum.apply_ready);
const canStable = !!(sum && sum.apply_stable_ready);
if ($('opt_btn_apply_gated')) $('opt_btn_apply_gated').disabled = !canGate;
if ($('opt_btn_apply_stable')) $('opt_btn_apply_stable').disabled = !canStable;
if ($('opt_btn_apply_mode')) {
const hasMode = !!(sum && (sum.compare_rows || []).some(r => r.source === 'mode'));
$('opt_btn_apply_mode').disabled = !done || !hasMode;
}
}
optunaRenderCompare(sum);
optunaRenderTop5(sum);
window._optunaLastSummary = sum || null;
optunaSetNav(job);
}
async function optunaFillFormFromCandidate(source, rank) {
// 캐시 없으면 다시 fetch
let cache = window._optunaCandidateCache;
if (!cache || !cache.params) {
await optunaShowCandidate(source || 'gated', rank || 1);
cache = window._optunaCandidateCache;
}
const p = (cache && cache.params) || {};
const strat = String((cache && cache.strategy) || '').toLowerCase();
const isUs = strat === 'us_momentum' || String(p.market || '').toUpperCase() === 'US';
const set = (id, val) => {
const el = $(id);
if (!el || val === undefined || val === null) return;
if (el.type === 'checkbox') el.checked = !!(val === true || val === 1 || val === '1');
else el.value = val;
};
const prefix = isUs ? 'usmom_' : 'mom_';
// 기간 = Optuna study 기간 (비교 시 필수)
if (cache.start) setDateVal(isUs ? 'usmom_start' : 'mom_start', cache.start);
if (cache.end) setDateVal(isUs ? 'usmom_end' : 'mom_end', cache.end);
set(prefix + 'sl', p.sl_pct);
set(prefix + 'tp', p.tp_pct);
set(prefix + 'tp_max', p.tp_max_pct);
set(prefix + 'slot_money', p.slot_money);
set(prefix + (isUs ? 'slots' : 'slots'), p.max_stocks || p.slots || p.mom_slots);
if (isUs) set('usmom_slots', p.max_stocks || p.slots);
else set('mom_slots', p.max_stocks || p.mom_slots || p.slots);
set(prefix + 'total_budget', p.total_budget_krw);
set(prefix + 'cooldown', p.cooldown_min);
set(prefix + 'smin', p.shoulder_min_high);
set(prefix + 'scut', p.shoulder_cut_pct);
set(prefix + 'trail', p.trail_pct);
set(prefix + 'trail_arm', p.trail_arm_pct);
set(prefix + 'max_hold', p.max_hold_bars);
set(prefix + 'ratchet', p.ratchet_tiers != null ? p.ratchet_tiers : '');
set(prefix + 'time_start', p.time_start_hm || p.mom_time_start_hm);
set(prefix + 'time_end', p.time_end_hm || p.mom_time_end_hm || p.mom_time_end);
set(prefix + 'max_daily', p.max_daily);
set(prefix + 'high_chase', p.high_chase_thr);
set(prefix + 'max_daily_chg', p.max_daily_chg);
set(prefix + 'min_price', p.min_price);
set(prefix + 'max_loss', p.max_loss_krw);
set(prefix + 'min_margin', p.min_margin);
set(prefix + 'rsi_min', p.mom_rsi_min);
set(prefix + 'rsi_max', p.mom_rsi_max);
set(prefix + 'vol_mult', p.mom_vol_mult);
set(prefix + 'vol_win', p.mom_vol_win);
set(prefix + 'max_from_open', p.mom_max_from_open_pct);
set(prefix + 'min_from_open', p.mom_min_from_open_pct);
set(prefix + 'chase_lookback', p.chase_lookback_min);
set(prefix + 'pullback_lookback', p.pullback_lookback_min);
set(prefix + 'pullback_min', p.pullback_min_pct);
set(prefix + 'pullback_max', p.pullback_max_pct);
set(prefix + 'setup_vol', p.setup_vol_max_mult);
set(prefix + 'setup_bear', p.setup_bear_bars_min);
set(prefix + 'ema_fast', p.ema_fast_period);
set(prefix + 'ema_slow', p.ema_slow_period);
set(prefix + 'eod_hm', p.eod_hm);
const defId = isUs ? 'usmom_use_defense' : 'mom_use_defense';
if ($(defId) && p.use_defense_filters !== undefined) $(defId).checked = !!p.use_defense_filters;
const mapCb = [
['use_high_chase_filter', isUs ? 'usmom_use_high_chase_f' : 'mom_use_high_chase_f'],
['use_daily_range_filter', isUs ? 'usmom_use_daily_range' : 'mom_use_daily_range'],
['use_ema_filter', isUs ? 'usmom_use_ema_f' : 'mom_use_ema_f'],
['use_rsi_max_filter', isUs ? 'usmom_use_rsi_max_f' : 'mom_use_rsi_max_f'],
['pattern_breakout', isUs ? 'usmom_pat_breakout' : 'mom_pat_breakout'],
['pattern_pullback', isUs ? 'usmom_pat_pullback' : 'mom_pat_pullback'],
['eod_enabled', isUs ? 'usmom_eod_enabled' : 'mom_eod_enabled'],
];
mapCb.forEach(([k, id]) => {
if ($(id) && p[k] !== undefined) $(id).checked = !!p[k];
});
// 이미 해당 탭이면 click 금지 — us_momentum 탭 click 시 usmomLoadStocks→핀 cfg 가
// Optuna 폼 반영을 즉시 덮어씀 (폼에 넣기 무효화 버그).
const wantTab = isUs ? 'us_momentum' : 'momentum';
const tab = document.querySelector(`[data-tab="${wantTab}"]`);
if (tab && !tab.classList.contains('active')) tab.click();
alert(
(isUs ? '해외 모멘텀' : '모멘텀') +
' 폼에 반영했습니다.\n기간 ' + (cache.start || '') + '~' + (cache.end || '') +
'\n같은 기간으로 백테 실행해 Optuna와 비교하세요.\n(DB 저장 아님)'
);
}
async function optunaApply(source, rank) {
if (!_optunaJobId) { alert('선택된 job 없음 — 최근 잡에서 보기 먼저'); return; }
const src = source || 'gated';
const rk = rank || 1;
let trailLine = '';
try {
const sum = (window._optunaLastSummary) || null;
const rec = sum && sum.daily_trail_recommend;
if (rec && rec.ok) {
trailLine =
`\n\n다단트레일 추천도 함께 반영됩니다:\n` +
`${rec.prefix}_DAILY_PROFIT_TRAIL_TIERS=${rec.tiers}\n` +
`(ARM=${Number(rec.arm_krw || 0).toLocaleString()} · 이후 운영탭에서 끄기/수정 가능)`;
} else if (rec && !rec.ok) {
trailLine = `\n\n다단트레일 추천 없음(${rec.reason || 'n/a'}) — TRAIL 미변경`;
}
} catch (e) { /* ignore */ }
const msg = (src === 'mode'
? 'mode_combo 파라미터를 DB에 적용할까요?\n(TIME_* 미변경 · 실매 즉시 영향)'
: src === 'stable'
? `안정(stable) #${rk} 을 DB에 적용할까요?\n(손실일·최악일 게이트 통과 · TIME_* 미변경 · 실매 즉시 영향)`
: `사후합격(gated) #${rk} 을 DB에 적용할까요?\n(TIME_* 미변경 · 실매 즉시 영향)`) + trailLine;
if (!confirm(msg)) return;
try {
const r = await fetch('/api/optuna/apply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ job_id: _optunaJobId, source: src, rank: rk }),
});
const j = await r.json();
if (!j.ok) { alert('❌ ' + (j.error || '적용 실패')); return; }
const m = j.metrics || {};
const ta = j.daily_trail_apply || {};
let trailOk = '';
if (ta.applied) {
const patch = ta.patch || {};
const tiersKey = Object.keys(patch).find(k => k.endsWith('_DAILY_PROFIT_TRAIL_TIERS'));
trailOk = `\n다단트레일: ${tiersKey ? patch[tiersKey] : '반영됨'}`;
} else if (ta.reason) {
trailOk = `\n다단트레일 미반영: ${ta.reason}`;
}
alert(
`${j.strategy} 적용 완료` +
(j.env_id != null ? ` (env_id=${j.env_id})` : '') +
`\ntrial #${m.optuna_trial_number ?? '—'} · PnL ${m.total_pnl} · WR ${m.win_rate}` +
trailOk +
`\n${j.note || ''}`,
);
} catch (e) {
alert('오류: ' + e);
}
}
async function optunaPollOnce() {
if (!_optunaJobId) {
// 외부 CLI / latest
try {
const r = await fetch('/api/optuna/active');
const j = await r.json();
if (j.running && j.running[0]) {
_optunaJobId = j.running[0].job_id;
localStorage.setItem(OPTUNA_LS_KEY, _optunaJobId);
optunaRenderJob(j.running[0]);
return;
}
if (j.latest) {
optunaRenderJob(j.latest);
if (j.latest.status === 'running') {
_optunaJobId = j.latest.job_id;
localStorage.setItem(OPTUNA_LS_KEY, _optunaJobId);
} else if (j.latest.status === 'done' || j.latest.status === 'error') {
optunaSetNav(j.latest);
}
} else if (j.external) {
optunaSetNav({
label: 'CLI Optuna',
status: 'running',
progress: { pct: 5, trials_done: '?', trials_total: '?' },
study_name: j.external.cmdline,
});
} else {
optunaSetNav(null);
}
} catch (e) { /* ignore */ }
return;
}
try {
const r = await fetch('/api/optuna/status/' + encodeURIComponent(_optunaJobId));
const j = await r.json();
if (!j.ok) {
_optunaJobId = '';
localStorage.removeItem(OPTUNA_LS_KEY);
return;
}
optunaRenderJob(j.job);
if (j.job.status === 'done' || j.job.status === 'error') {
// 완료 후에도 상단 바는 잠시 유지 (클릭해 결과 확인)
}
} catch (e) { /* ignore */ }
}
function optunaStartPolling() {
if (_optunaPollTimer) clearInterval(_optunaPollTimer);
optunaPollOnce();
_optunaPollTimer = setInterval(optunaPollOnce, 3000);
}
function optunaSelectedStrategies() {
const order = ['momentum', 'us_momentum', 'tail', 'breakout', 'scalp'];
const checked = new Set(
Array.from(document.querySelectorAll('.opt-strat-cb:checked')).map(cb => cb.value)
);
return order.filter(s => checked.has(s));
}
function optunaPickKr4() {
const kr = new Set(['momentum', 'tail', 'breakout', 'scalp']);
document.querySelectorAll('.opt-strat-cb').forEach(cb => {
cb.checked = kr.has(cb.value);
});
}
function optunaPickNone() {
document.querySelectorAll('.opt-strat-cb').forEach(cb => { cb.checked = false; });
}
async function optunaStart() {
const strategies = optunaSelectedStrategies();
const start = $('opt_start')?.value;
const end = $('opt_end')?.value;
const trials = parseInt($('opt_trials')?.value || '200', 10);
const mode = $('opt_mode')?.value || 'tpe';
const univSrc = univHistorySourceParam('opt_univ_history_source');
const candleSrc = $('opt_candle_source')?.value || '';
const tickSrc = $('opt_tick_source')?.value || '';
const obSrc = $('opt_ob_source')?.value || '';
if (!strategies.length) { alert('전략을 1개 이상 체크하세요'); return; }
if (!start || !end) { alert('시작·종료일을 입력하세요'); return; }
const how = strategies.length >= 2 ? `순차 ${strategies.length}` : strategies[0];
const srcLabel = univSrc === 'ls' ? 'LS' : '키움';
if (!confirm(`Optuna 시작?\n${how}: ${strategies.join(', ')}\n${start}~${end} trials=${trials}\n이력소스=${srcLabel}\n(DB 미적용)`)) return;
try {
const r = await fetch('/api/optuna/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
strategies, start, end, trials, mode,
universe_history_source: univSrc,
candle_source: candleSrc,
tick_source: tickSrc,
ob_source: obSrc,
}),
});
const j = await r.json();
if (!j.ok) { alert('❌ ' + (j.error || '시작 실패')); return; }
_optunaJobId = j.job.job_id;
localStorage.setItem(OPTUNA_LS_KEY, _optunaJobId);
optunaRenderJob(j.job);
optunaStartPolling();
optunaRefreshJobs();
} catch (e) {
alert('오류: ' + e);
}
}
async function optunaStop() {
if (!_optunaJobId) return;
if (!confirm('실행 중 Optuna를 중지할까요?')) return;
try {
const r = await fetch('/api/optuna/stop/' + encodeURIComponent(_optunaJobId), { method: 'POST' });
const j = await r.json();
if (!j.ok) { alert(j.error || '중지 실패'); return; }
optunaRenderJob(j.job);
optunaRefreshJobs();
} catch (e) { alert('오류: ' + e); }
}
async function optunaRefreshJobs() {
try {
const sort = $('opt_jobs_sort')?.value || 'started';
const r = await fetch('/api/optuna/jobs?limit=20&sort=' + encodeURIComponent(sort));
const j = await r.json();
const tb = $('opt_jobs_tbody');
if (!tb) return;
if (!j.ok || !(j.jobs || []).length) {
tb.innerHTML = '<tr><td colspan="8" class="text-muted">잡 없음</td></tr>';
return;
}
tb.innerHTML = j.jobs.map(job => {
const prog = job.progress || {};
const jid = job.job_id;
const label = job.label || job.strategy || '';
return `<tr>
<td><code style="font-size:10px">${jid}</code></td>
<td>${label}</td>
<td>${job.start || ''}~${job.end || ''}</td>
<td>${job.status || ''}</td>
<td>${prog.trials_done ?? '—'}/${prog.trials_total ?? job.trials ?? '—'}</td>
<td>${job.started_at || ''}</td>
<td>${job.finished_at || '—'}</td>
<td><button class="btn btn-sm btn-outline-secondary py-0" onclick="optunaWatch('${jid}')">보기</button></td>
</tr>`;
}).join('');
} catch (e) {
const tb = $('opt_jobs_tbody');
if (tb) tb.innerHTML = `<tr><td colspan="8" class="text-danger">${e}</td></tr>`;
}
}
function optunaWatch(jobId) {
_optunaJobId = jobId;
localStorage.setItem(OPTUNA_LS_KEY, jobId);
optunaPollOnce();
optunaStartPolling();
}
function optunaBoot() {
optunaLoadDefaults();
optunaStartPolling();
}
// ────────────────────────────────────────────
// 상단 매매 모의/실전 (KIS_MOCK) — DB 저장 + kis_trader_main 재시작
// ────────────────────────────────────────────
let _kisMockBusy = false;
let _kisMockCurrent = null; // true=모의, false=실전, null=미로드
/**
* WS 프로바이더 라벨 생성.
* @param {string} prov - "kiwoom" | "kis" | "ls" | ...
* @param {boolean} isMock
* @returns {string} e.g. "키움(실)" | "KIS(모)"
*/
function _wsProvLabel(prov, isMock) {
const nm = (prov || '').toLowerCase();
const name = nm === 'kiwoom' ? '키움' : nm === 'kis' ? 'KIS' : nm === 'ls' ? 'LS' : (prov || '?').toUpperCase();
return `${name}(${isMock ? '모' : '실'})`;
}
function kisMockApplyUi(d) {
const mock = !!(d && d.kis_mock);
_kisMockCurrent = mock;
const btnM = $('kis_mock_btn_mock');
const btnL = $('kis_mock_btn_live');
if (btnM) {
btnM.classList.toggle('active', mock);
btnM.classList.toggle('btn-warning', mock);
btnM.classList.toggle('btn-outline-warning', !mock);
btnM.disabled = _kisMockBusy;
}
if (btnL) {
btnL.classList.toggle('active', !mock);
btnL.classList.toggle('btn-danger', !mock);
btnL.classList.toggle('btn-outline-danger', mock);
btnL.disabled = _kisMockBusy;
}
const st = $('kis_mock_status');
if (st) {
const bot = d && d.bot_active ? 'active' : ((d && d.bot_status) || '?');
const label = (d && d.label) || (mock ? '모의' : '실전');
st.textContent = label + ' · ' + bot;
st.className = mock ? 'text-warning' : 'text-danger';
st.style.fontSize = '11px';
st.style.whiteSpace = 'nowrap';
}
const lss = $('live_source_status');
if (lss && d) {
// 프로바이더 모의/실전 판정
const tickProv = (d.tick_provider || 'kiwoom').toLowerCase();
const obProv = (d.ob_provider || 'kiwoom').toLowerCase();
const tickMock = tickProv.includes('kis') ? !!d.kis_price_mock : !!d.kiwoom_mock;
const obMock = obProv.includes('kis') ? !!d.kis_price_mock : !!d.kiwoom_mock;
const tickLbl = _wsProvLabel(tickProv, tickMock);
const obLbl = _wsProvLabel(obProv, obMock);
// 틱 DB 적재 표시
const scopeShort = (d.tick_db_scope || '전체').replace('candidates', '후보');
const dbLbl = d.tick_db_enabled ? `DB:${scopeShort}` : 'DB:OFF';
lss.textContent = `시세:${tickLbl} | 호가:${obLbl} | ${dbLbl}`;
// hover tooltip — 전체 상세 (캔들 소스 포함)
const c = d.candle_source || '전체';
lss.title = [
`캔들 소스: ${c}`,
`시세 우선: ${_wsProvLabel(tickProv, tickMock)} → 폴백 자동`,
`호가 우선: ${_wsProvLabel(obProv, obMock)} → 폴백 자동`,
`KIS 시세WS: 실전 고정 (is_mock=false)`,
`틱 DB 저장: ${d.tick_db_enabled ? 'ON (' + (d.tick_db_scope || '?') + ')' : 'OFF'}`,
].join('\n');
lss.style.cursor = 'help';
}
}
async function kisMockLoad() {
try {
const r = await fetch('/api/ops/kis_mock');
const d = await r.json();
if (!d || !d.ok) {
const st = $('kis_mock_status');
if (st) st.textContent = (d && d.error) || '조회실패';
return;
}
kisMockApplyUi(d);
} catch (e) {
const st = $('kis_mock_status');
if (st) st.textContent = '조회오류';
}
}
async function kisMockSet(wantMock) {
if (_kisMockBusy) return;
if (_kisMockCurrent === wantMock) {
await kisMockLoad();
return;
}
const label = wantMock ? '모의' : '실전';
const msg = wantMock
? '매매를 모의 계좌로 전환합니다.\n시세는 실키 유지 · kis_trader_main 이 재시작됩니다.\n계속할까요?'
: '⚠️ 매매를 실전 계좌로 전환합니다.\n실주문이 나갑니다. 시세는 실키 유지 · 봇이 재시작됩니다.\n정말 전환할까요?';
if (!confirm(msg)) return;
_kisMockBusy = true;
kisMockApplyUi({ kis_mock: _kisMockCurrent, label: _kisMockCurrent ? '모의' : '실전', bot_active: false, bot_status: '재시작중' });
const st = $('kis_mock_status');
if (st) {
st.textContent = '저장·재시작중…';
st.className = 'text-muted';
}
try {
const r = await fetch('/api/ops/kis_mock', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kis_mock: !!wantMock }),
});
const d = await r.json().catch(() => ({}));
if (!r.ok || !d.ok) {
alert(
(d && d.error) ||
('전환 실패 (HTTP ' + r.status + ')\n' +
'db_ok=' + (d && d.db_ok) + ' restart_ok=' + (d && d.restart_ok))
);
await kisMockLoad();
return;
}
kisMockApplyUi(d);
alert('✅ 매매 ' + label + ' 전환 완료\n봇: ' + (d.bot_status || '') + '\n' + (d.note || ''));
} catch (e) {
alert('전환 오류: ' + e);
await kisMockLoad();
} finally {
_kisMockBusy = false;
await kisMockLoad();
}
}
function kisMockBoot() {
kisMockLoad();
}
// ────────────────────────────────────────────
// 최초 로드
// ※ loadActual 은 /api/env/params 날짜 확정 후 호출 (위 fetch then)
// ────────────────────────────────────────────
optunaBoot();
kisMockBoot();