// ────────────────────────────────────────────
// 유틸
// ────────────────────────────────────────────
const $ = id => document.getElementById(id);
/** 서버/로컬 휴장일 YYYY-MM-DD 집합 — dashInitDate 등 조기 호출보다 먼저 선언(TDZ 방지) */
let _krHolidays = new Set();
const fmt = n => n == null ? '-' : Number(n).toLocaleString('ko-KR');
/** 원화 가격·평가금 — 소수점 없이 정수 (KIS 주식 호가 단위) */
const fmtWon = n => {
if (n == null || n === '' || !Number.isFinite(Number(n))) return '—';
return Math.round(Number(n)).toLocaleString('ko-KR');
};
/** 원화 금액·손익 — 소수점 없이 정수 + '원' (대시보드·요약 카드) */
const fmtKrw = n => {
const w = fmtWon(n);
return w === '—' ? w : w + '원';
};
/** 달러 금액 — $1,234.56 */
const fmtUsd = n => {
if (n == null || n === '' || !Number.isFinite(Number(n))) return '—';
const v = Number(n);
const sign = v < 0 ? '-' : '';
return sign + '$' + Math.abs(v).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
};
/** 표시용 환율 (캐시/폴백). 외부 HTTP 안 탐. */
let _displayFxRate = 0;
function setDisplayFxRate(r) {
const v = Number(r);
if (Number.isFinite(v) && v > 0) _displayFxRate = v;
}
function getDisplayFxRate(fallback) {
const f = Number(fallback);
if (Number.isFinite(f) && f > 0) return f;
if (_displayFxRate > 0) return _displayFxRate;
return 0;
}
/** 해외 탭: $USD + (원 · 환율) — fxRate 없으면 원만 생략 */
const fmtUsdWithKrw = (usd, fxRate) => {
const u = fmtUsd(usd);
if (u === '—') return u;
const r = getDisplayFxRate(fxRate);
if (!Number.isFinite(r) || r <= 0) return u;
const krw = Math.round(Number(usd) * r);
return `${u} (${fmtWon(krw)}원 · ${r.toFixed(2)})`;
};
/** 요약 카드용 — HTML 없이 텍스트 ($ + 원) */
const fmtUsdWithKrwText = (usd, fxRate) => {
const u = fmtUsd(usd);
if (u === '—') return u;
const r = getDisplayFxRate(fxRate);
if (!Number.isFinite(r) || r <= 0) return u;
return `${u} (${fmtWon(Math.round(Number(usd) * r))}원)`;
};
/** 수익률(%) — 거래내역·가상체결 테이블 공용 */
const fmtPct = n => {
if (n == null || n === '' || !Number.isFinite(Number(n))) return '—';
const v = Number(n);
return (v >= 0 ? '+' : '') + v.toFixed(2) + '%';
};
/** 틱 분봉커버 문구 — 구독구간(주) + 전체(참고). REST 웜업 봉 때문에 전체가 낮아 보임. */
function fmtTickCoverageLabel(tickMeta) {
const m = tickMeta || {};
const sub = m.tick_bar_coverage_pct_subscribed != null
? m.tick_bar_coverage_pct_subscribed
: m.tick_bar_coverage_pct;
const all = m.tick_bar_coverage_pct_all;
const friend = m.tick_bar_coverage_pct_friend;
const codes = (m.tick_codes_with_data != null && m.tick_codes_total != null)
? `${m.tick_codes_with_data}/${m.tick_codes_total}종`
: '';
let s = `구독구간 ${sub != null ? sub : '—'}%`;
if (m.tick_bar_coverage_pct_traded != null) {
const tn = m.tick_codes_traded != null ? `${m.tick_codes_traded}종` : '';
s += ` · 거래종목 ${m.tick_bar_coverage_pct_traded}%` + (tn ? `(${tn})` : '');
}
if (all != null) s += ` · 전체 ${all}%`;
if (friend != null && friend !== all) s += ` · 틱종목 ${friend}%`;
if (codes) s += ` (${codes})`;
return s;
}
/** 가상 거래 내역 — 매도/청산 시각 기준 최신순 (API가 최신순이어도 프론트에서 재정렬) */
function tradeExitSortKey(t) {
const keys = ['exit_time', 'sell_time', 'sell_date', 'buy_time', 'entry_time', 'buy_date'];
for (const k of keys) {
const v = t && t[k];
if (v != null && String(v).trim()) {
return String(v).replace(/[-: T]/g, '').slice(0, 14);
}
}
return '';
}
function tradesNewestFirst(trades) {
return [...(trades || [])].sort((a, b) => tradeExitSortKey(b).localeCompare(tradeExitSortKey(a)));
}
/** 가상 거래 내역 — 전략 공통 렌더·정렬 */
const _virtualTradesCache = {};
const _virtualTradesSort = {};
const _virtualTradesOpts = {};
function fmtTradeTime(raw) {
const s = String(raw || '').trim();
if (!s) return '';
// ISO 형식('2026-06-26 09:30:25') — 초(SS)까지 표시. 날짜만(<=10자)이면 그대로.
if (s.includes('-') && (s.includes(':') || s.length <= 10)) {
return s.length <= 10 ? s : s.slice(0, 19);
}
const d = s.replace(/[-: T]/g, '');
if (d.length >= 14) {
return d.slice(0, 4) + '-' + d.slice(4, 6) + '-' + d.slice(6, 8) + ' '
+ d.slice(8, 10) + ':' + d.slice(10, 12) + ':' + d.slice(12, 14);
}
if (d.length >= 12) {
return d.slice(0, 4) + '-' + d.slice(4, 6) + '-' + d.slice(6, 8) + ' '
+ d.slice(8, 10) + ':' + d.slice(10, 12) + ':00';
}
if (d.length >= 8) return d.slice(0, 4) + '-' + d.slice(4, 6) + '-' + d.slice(6, 8);
return s;
}
function _tradeTimeSortKey(raw) {
const s = String(raw || '').trim();
if (!s) return '';
return s.replace(/[-: T]/g, '').slice(0, 14);
}
function normalizeVirtualTrade(t, meta) {
meta = meta || {};
const isUsd = String(meta.currency || '').toUpperCase() === 'USD';
const roundPx = v => {
const n = Number(v);
if (!Number.isFinite(n)) return 0;
return isUsd ? Math.round(n * 10000) / 10000 : Math.round(n);
};
const roundMoney = v => {
const n = Number(v);
if (!Number.isFinite(n)) return 0;
return isUsd ? Math.round(n * 100) / 100 : Math.round(n);
};
const ep = roundPx(t.buy_price ?? t.avg_price ?? t.entry ?? 0);
const xp = roundPx(t.sell_price ?? t.exit_price ?? t.exit ?? 0);
const qty = Number(t.qty || 0) || (ep > 0 ? Math.max(1, Math.floor(1000000 / ep)) : 0);
const pnl = roundMoney(t.pnl ?? t.realized_pnl ?? t.unrealized_pnl ?? 0);
let rateNum = Number(t.profit_rate);
if (!Number.isFinite(rateNum) && ep > 0) rateNum = (xp - ep) / ep * 100;
if (!Number.isFinite(rateNum)) rateNum = 0;
const isOpen = !!t.is_open;
const buyRaw = t.buy_time || t.entry_time || t.buy_date || '';
const sellRaw = isOpen ? '' : (t.sell_time || t.exit_time || t.sell_date || '');
const code = t.code || meta.code || '';
const name = t.name || meta.name || '';
const fxRate = (() => {
const r = Number(t.fx_rate);
if (Number.isFinite(r) && r > 0) return r;
const ymd = String(t.fx_ymd || sellRaw || buyRaw || '').slice(0, 10);
const map = meta.fxRates || {};
const m = Number(map[ymd]);
if (Number.isFinite(m) && m > 0) return m;
return getDisplayFxRate(meta.fxRate);
})();
const pnlKrw = (() => {
const k = Number(t.pnl_krw);
if (Number.isFinite(k)) return Math.round(k);
if (Number.isFinite(fxRate) && fxRate > 0) return Math.round(pnl * fxRate);
return NaN;
})();
return {
code,
name,
buyRaw,
sellRaw,
buyKey: _tradeTimeSortKey(buyRaw),
sellKey: isOpen ? _tradeTimeSortKey(buyRaw) : _tradeTimeSortKey(sellRaw),
ep,
xp,
qty,
pnl,
pnlKrw,
fxRate,
isUsd,
isOpen,
rateNum,
hold: t.hold_min ?? t.hold_days ?? t.hold_minutes ?? 0,
reason: t.sell_reason || t.reason || '',
rsi: t.rsi_entry,
entrySource: t.entry_source_label || t.entry_source || '',
exitSource: t.exit_source_label || t.exit_source || '',
debugTick: t.debug_tick || '',
parityException: !!(t.parity_exception) || _detectParityExceptionCauses(t).length > 0,
parityCause: t.parity_cause || _detectParityExceptionCauses(t).join('+'),
cumPnl: (() => {
const v = Number(t.cum_pnl);
if (!Number.isFinite(v)) return NaN;
return isUsd ? Math.round(v * 100) / 100 : Math.round(v);
})(),
cumReturnPct: Number(t.cum_return_pct ?? NaN),
};
}
function _detectParityExceptionCauses(t) {
const causes = [];
const es = String(t.entry_source || '').toLowerCase();
const xs = String(t.exit_source || '').toLowerCase();
const reason = String(t.sell_reason || t.reason || '');
const el = String(t.entry_source_label || t.entrySource || '');
const xl = String(t.exit_source_label || t.exitSource || '');
if (['ohlc_low', 'ohlc_fallback', 'ohlc_high', 'ohlc_intrabar', 'ohlc_bar'].includes(es)
|| /진입OHLC|OHLC 폴백|OHLC high/i.test(el)) {
causes.push('진입OHLC');
}
if (['ohlc_bar', 'ohlc_fallback'].includes(xs) || /청산OHLC|^OHLC$/i.test(xl)) {
causes.push('청산OHLC');
}
if (['eod_flat', 'wallclock_eod'].includes(xs) || /틱공백EOD/i.test(xl)) {
causes.push('틱공백EOD');
}
if (/ghost_purge/i.test(reason)) causes.push('ghost_purge');
return causes;
}
function sortVirtualTradeRows(rows, mode) {
const list = [...rows];
const cmpExit = (a, b) => String(a.sellKey).localeCompare(String(b.sellKey));
const cmpBuy = (a, b) => String(a.buyKey).localeCompare(String(b.buyKey));
switch (mode) {
case 'exit_asc': return list.sort(cmpExit);
case 'buy_desc': return list.sort((a, b) => cmpBuy(b, a));
case 'buy_asc': return list.sort(cmpBuy);
case 'pnl_desc': return list.sort((a, b) => b.pnl - a.pnl);
case 'pnl_asc': return list.sort((a, b) => a.pnl - b.pnl);
case 'rate_desc': return list.sort((a, b) => b.rateNum - a.rateNum);
case 'rate_asc': return list.sort((a, b) => a.rateNum - b.rateNum);
case 'exit_desc':
default: return list.sort((a, b) => cmpExit(b, a));
}
}
/** 실매·백테 거래내역 공통 컨텍스트: 한도 · 장중 누적 최고 · 최종 누적 */
function fillTradePnLContext(elOrId, opts) {
opts = opts || {};
const el = typeof elOrId === 'string' ? $(elOrId) : elOrId;
if (!el) return;
const tb = Number(opts.totalBudget || 0);
const peak = Number(opts.peakCum || 0);
const peakAt = opts.peakAt ? fmtTradeTime(opts.peakAt) : '';
const label = opts.label || '거래내역';
const isUsd = String(opts.currency || '').toUpperCase() === 'USD';
const fx = Number(opts.fxRate || 0);
const fmtMoney = (n) => isUsd ? fmtUsdWithKrwText(n, fx) : (fmtWon(n) + '원');
let line = `📋 ${label} · 한도 ${tb > 0 ? (isUsd ? fmtUsd(tb) : fmtWon(tb) + '원') : '—'}`;
if (peak > 0 && peakAt) {
line += ` | 장중 누적 최고 +${fmtMoney(peak)} (${peakAt})`;
}
if (opts.totalPnl != null && Number.isFinite(Number(opts.totalPnl))) {
const pnlHtml = isUsd
? fmtUsdWithKrw(opts.totalPnl, fx > 0 ? fx : (Number(opts.totalPnlKrw) ? (Number(opts.totalPnl) ? Number(opts.totalPnlKrw) / Number(opts.totalPnl) : fx) : fx))
: fmtKrw(opts.totalPnl);
// 원 환산 금액이 별도로 있으면 그걸로 병기
if (isUsd && opts.totalPnlKrw != null && Number.isFinite(Number(opts.totalPnlKrw))) {
line += ` | 최종 누적 ${fmtUsd(opts.totalPnl)} (${fmtWon(opts.totalPnlKrw)}원)`;
} else {
line += ` | 최종 누적 ${pnlHtml}`;
}
}
el.innerHTML = line;
el.style.display = 'block';
}
function _syncTradeSortButtons(tbodyId, mode) {
document.querySelectorAll(`.trade-sort-bar[data-tbody="${tbodyId}"] .trade-sort-btn`).forEach(btn => {
btn.classList.toggle('active', btn.dataset.sort === mode);
});
}
function attachCumPnlClient(trades, totalBudget) {
const out = (trades || []).map(t => Object.assign({}, t));
if (!out.length) return out;
if (out.every(t => Number.isFinite(Number(t.cum_pnl)))) return out;
const ordered = [...out].sort((a, b) => tradeExitSortKey(a).localeCompare(tradeExitSortKey(b)));
let cum = 0;
const tb = Number(totalBudget || 0);
for (const t of ordered) {
if (t.is_open) continue;
cum += Number(t.pnl ?? t.realized_pnl ?? 0);
t.cum_pnl = Math.round(cum);
t.cum_return_pct = tb > 0 ? Math.round((cum / tb) * 10000) / 100 : 0;
}
return out;
}
function renderVirtualTrades(tbodyId, trades, opts) {
opts = opts || {};
let src = trades || [];
if (opts.showCumulative) {
src = attachCumPnlClient(src, opts.totalBudget);
}
_virtualTradesCache[tbodyId] = src;
_virtualTradesOpts[tbodyId] = opts;
if (!_virtualTradesSort[tbodyId]) _virtualTradesSort[tbodyId] = 'exit_desc';
const mode = _virtualTradesSort[tbodyId];
const meta = Object.assign({}, opts.meta || {}, {
currency: opts.currency || (opts.meta && opts.meta.currency) || '',
fxRates: opts.fxRates || (opts.meta && opts.meta.fxRates) || {},
});
const isUsd = String(meta.currency || '').toUpperCase() === 'USD';
const defaultFx = getDisplayFxRate(meta.fxRate || opts.fxRate);
const fmtPx = (n, fx) => isUsd ? fmtUsdWithKrw(n, getDisplayFxRate(fx || defaultFx)) : fmtWon(n);
const fmtPnlCell = (n, fx) => isUsd ? fmtUsdWithKrw(n, getDisplayFxRate(fx || defaultFx)) : fmtWon(n);
const rows = sortVirtualTradeRows(
(_virtualTradesCache[tbodyId] || []).map(t => normalizeVirtualTrade(t, Object.assign({}, meta, { fxRate: defaultFx }))),
mode,
);
const tbody = $(tbodyId);
if (!tbody) return;
tbody.innerHTML = '';
rows.forEach(r => {
const pnlCls = r.pnl > 0 ? 'text-pnl-pos' : (r.pnl < 0 ? 'text-pnl-neg' : '');
const rateBadge = r.isOpen
? 'badge-flat'
: (r.rateNum > 0 ? 'badge-win' : (r.rateNum < 0 ? 'badge-loss' : 'badge-flat'));
const nameCell = (r.name || r.code)
? `${r.name || r.code}
${r.code || ''}`
: (r.code || '-');
const reasonHtml = `${r.reason || '-'}`;
const debugHtml = r.debugTick
? `${r.debugTick}`
: (r.entrySource || r.exitSource
? `${r.entrySource || '-'}→${r.exitSource || '-'}`
: '-');
const rowFx = getDisplayFxRate(r.fxRate || defaultFx);
const cumPnlHtml = Number.isFinite(r.cumPnl)
? `${fmtPnlCell(r.cumPnl, rowFx)}`
: '-';
const cumPctHtml = Number.isFinite(r.cumReturnPct)
? `${fmtPct(r.cumReturnPct)}`
: '-';
const sellCell = r.isOpen
? '보유중'
: `${fmtTradeTime(r.sellRaw)}`;
const exitPxCell = r.isOpen
? `${fmtPx(r.xp, rowFx)} 현재가`
: fmtPx(r.xp, rowFx);
const pnlCell = r.isOpen
? `${fmtPnlCell(r.pnl, rowFx)} 평가`
: fmtPnlCell(r.pnl, rowFx);
const isParityEx = !!r.parityException;
const rowCls = [
r.isOpen ? 'trade-row-open' : '',
isParityEx ? 'trade-row-parity-ex' : '',
].filter(Boolean).join(' ');
const causeBadge = isParityEx
? `${r.parityCause || '예외'}`
: '';
const rowStyle = r.isOpen && !isParityEx ? ' style="background:rgba(210,153,34,.06)"' : '';
const liveAttrs = r.isOpen && r.code && r.ep > 0 && r.qty > 0
? ` data-live-code="${String(r.code).replace(/"/g, '')}" data-live-buy="${r.ep}" data-live-qty="${r.qty}" data-live-usd="${r.isUsd ? '1' : '0'}"`
: '';
let html = `
' + (x.ws_tr_key || '') + '${sid}${sid}${liveSrc}' + it.strategy + '' : '—';
const sellBtn = it.can_sell
? ``
: '매도불가';
const isUs = String(it.strategy || '').toUpperCase() === 'US_MOMENTUM'
|| (!/^\d{6}$/.test(String(it.code || '')));
const buyRef = Number(it.broker_avg_price > 0 ? it.broker_avg_price : it.buy_price) || 0;
const qtyRef = Number(it.broker_qty != null && it.broker_qty > 0 ? it.broker_qty : it.db_qty) || 0;
const liveAttrs = (String(it.code || '') && buyRef > 0 && qtyRef > 0)
? ` data-live-code="${String(it.code).replace(/"/g, '')}" data-live-buy="${buyRef}" data-live-qty="${qtyRef}" data-live-usd="${isUs ? '1' : '0'}"`
: '';
tbody.insertAdjacentHTML('beforeend', `
${job.log_path}` : '';
usmomOptSetStatus(
`${job.label || '종목 Optuna'} · ${st}` +
(job.error ? `${job.result_json}` : '') +
log,
st === 'error'
);
const tail = $('usmom_opt_log_tail');
if (tail) {
const t = (job.log_tail || '').trim();
if (t) {
tail.textContent = t;
tail.classList.remove('d-none');
} else if (st !== 'running') {
tail.classList.add('d-none');
}
}
usmomOptSetButtons(st === 'running', st === 'done');
if (st === 'done' && job.result_summary) {
usmomStockOptunaRenderResults(job.result_summary);
}
// 상단 네비 프로그레스도 동일 갱신 (Optuna 탭과 공유 바)
if (typeof optunaSetNav === 'function') optunaSetNav(job);
}
async function usmomStockOptunaView(source, rank) {
if (!_usmomOptJobId) {
_usmomOptJobId = localStorage.getItem(USMOM_OPT_LS_KEY) || '';
}
if (!_usmomOptJobId) { alert('종목 Optuna job 없음'); return; }
const src = source || 'learn';
const rk = Number(rank) || 1;
_usmomOptSelected = { source: src, rank: rk };
usmomOptSyncSelectedButtons();
const title = $('usmom_opt_detail_title');
const met = $('usmom_opt_detail_metrics');
const pre = $('usmom_opt_detail_params');
if (title) title.textContent = `로딩 ${src} #${rk}…`;
if (met) met.textContent = '불러오는 중…';
try {
const qs = new URLSearchParams({
job_id: _usmomOptJobId,
source: src,
rank: String(rk),
});
const r = await fetch('/api/optuna/candidate?' + qs.toString());
const j = await r.json();
if (!j.ok) {
if (met) met.textContent = '❌ ' + (j.error || '실패');
if (pre) { pre.textContent = '—'; pre.classList.add('d-none'); }
return;
}
const m = j.metrics || {};
if (title) {
title.textContent =
`${j.strategy || 'us_momentum'} · ${m.label || src} · trial ${
m.optuna_trial_number != null ? '#' + m.optuna_trial_number : '—'
}`;
}
if (met) {
met.innerHTML =
`거래 ${usmomOptFmtUsdOrNum(m.total_trades, 0)} · ` +
`승률 ${usmomOptFmtUsdOrNum(m.win_rate, 1)}% · ` +
`PF ${usmomOptFmtUsdOrNum(m.pf, 2)} · ` +
`PnL ${usmomOptFmtUsdOrNum(m.total_pnl)}` +
(j.start && j.end ? ` · ${j.start}~${j.end}` : '');
}
if (pre) {
pre.textContent = JSON.stringify(j.params_preview || j.params_full || {}, null, 2);
pre.classList.remove('d-none');
}
window._optunaJobId = _usmomOptJobId;
window._optunaCandidateCache = {
strategy: j.strategy || 'us_momentum',
start: j.start,
end: j.end,
params: j.params_full || j.params_preview || {},
};
usmomStockOptunaRenderResults(window._usmomOptLastSummary);
} catch (e) {
if (met) met.textContent = '오류: ' + e;
}
}
async function usmomStockOptunaFillSelected() {
if (!_usmomOptSelected) { alert('먼저 Top에서「보기」로 후보를 고르세요'); return; }
return usmomStockOptunaFillForm(_usmomOptSelected.source, _usmomOptSelected.rank);
}
async function usmomStockOptunaApplySelected() {
if (!_usmomOptSelected) { alert('먼저 Top에서「보기」로 후보를 고르세요'); return; }
return usmomStockOptunaApply(_usmomOptSelected.source, _usmomOptSelected.rank);
}
async function usmomStockOptunaStart() {
const code = ($('usmom_stock_code')?.value || '').trim().toUpperCase();
if (!code) { alert('종목을 선택하세요'); return; }
usmomOptSyncDatesFromMain();
let start = ($('usmom_opt_start')?.value || '').trim() || ($('usmom_start')?.value || '').trim();
let end = ($('usmom_opt_end')?.value || '').trim() || ($('usmom_end')?.value || '').trim();
if (!start || !end) { alert('시작·종료일을 입력하세요'); return; }
usmomOptSyncDatesToMain();
if ($('usmom_bt_codes')) $('usmom_bt_codes').value = code;
const trials = parseInt($('usmom_opt_trials')?.value || '100', 10) || 100;
const mode = ($('usmom_opt_mode')?.value || 'tpe').trim() || 'tpe';
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,
}),
});
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),
mom_time_end: parseInt($('mom_time_end_buy').value, 10),
mom_max_from_open_pct: parseFloat($('mom_max_from_open')?.value || '999'),
mom_min_from_open_pct: parseFloat($('mom_min_from_open')?.value || '-999'),
ob_filter: !!($('mom_ob_filter')?.checked),
pg_filter: !!($('mom_pg_filter')?.checked),
daily_trail_tiers: ($('mom_daily_trail_tiers') && $('mom_daily_trail_tiers').value.trim()) || '',
daily_profit_mode: ($('mom_daily_profit_mode') && $('mom_daily_profit_mode').value) || 'trailing',
daily_profit_enabled: !!($('mom_daily_profit_enabled')?.checked),
daily_risk_budget_enabled: !!($('mom_daily_risk_budget_enabled')?.checked),
eod_enabled: !!($('mom_eod_enabled')?.checked),
eod_hm: ($('mom_eod_hm') && $('mom_eod_hm').value.trim()) || '15:25',
backtest_skip_pre_subscribe: !!($('mom_skip_pre_sub')?.checked),
};
if (!confirm('💾 모멘텀 봇(MOMENTUM_*)에 현재 탭 값을 저장할까요?\n실행 중이면 다음 루프부터 반영됩니다.\n다단트레일: ' + (body.daily_profit_enabled ? 'ON' : 'OFF') + ' · 리스크버짓(B): ' + (body.daily_risk_budget_enabled ? 'ON' : 'OFF'))) return;
fetch('/api/backtest/momentum/save_config', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body),
}).then(r => r.json()).then(d => {
if (d.error) { alert('❌ ' + d.error); return; }
btAddPresetOption('mom_daily_trail_preset', $('mom_daily_trail_tiers') && $('mom_daily_trail_tiers').value);
const byTbl = d.saved_by_table || {};
const tblLines = Object.entries(byTbl).map(([t, ks]) => ` ${t}: ${ks.join(', ')}`).join('\n');
alert(`✅ 저장 완료 (env_id: ${d.env_id})\n테이블:\n${tblLines || d.saved_keys?.join(', ')}`);
}).catch(e => alert('오류: ' + e));
}
function renderMomentumBacktest(d) {
if (!d || d.error) {
alert('❌ ' + (d && d.error ? d.error : '백테스트 응답 없음'));
return;
}
if (!d.summary) {
alert('백테스트 응답 오류 (summary 없음)');
return;
}
const s = d.summary || {};
const trades = d.trades || [];
const p = d.params || {};
const momRa = $('mom_result_area');
if (momRa) momRa.style.display = 'block';
const momPb = $('mom_params_bar');
if (momPb) momPb.style.display = 'block';
const setTxt = (id, txt) => { const el = $(id); if (el) el.textContent = txt; };
const momUnivLabel = formatUniverseLabel(p);
const effTp = p.effective_tp_pct != null ? p.effective_tp_pct : p.tp_pct;
const periodHint = (p.start && p.end) ? ` | ${p.start}~${p.end}` : '';
const patParts = [];
if (p.pattern_breakout) patParts.push('돌파');
if (p.pattern_pullback) patParts.push('눌림');
const patLabel = patParts.length ? patParts.join('+') : '패턴OFF';
const rsiLabel = p.use_rsi_max_filter
? `RSI ${p.mom_rsi_min}~${p.mom_rsi_max}`
: `RSI≥${p.mom_rsi_min}`;
const momSummaryLine =
`${momUnivLabel} ` +
`${patLabel}(${p.chase_lookback_min || '—'}분) | ${rsiLabel} | 거래량×${p.mom_vol_mult} | ` +
`손절-${p.sl_pct}% | 어깨(${p.shoulder_min_high}%/${p.shoulder_cut_pct}%)` +
`${p.ratchet_tiers ? '·래칫' + p.ratchet_tiers : ''} | ` +
`익절상한+${effTp}%${periodHint} | ` +
`EMA ${p.use_ema_filter ? `ON(${p.ema_fast_period}/${p.ema_slow_period})` : 'OFF'} | ` +
`1회 ${Number(p.slot_money||0).toLocaleString()}원 · 동시${p.max_stocks||'?'}종 · 한도 ${Number(p.total_budget_krw||0).toLocaleString()}원 | ` +
`쿨다운${p.cooldown_min}분 | 일${p.max_daily || '—'}회 | 종목수 ${p.codes_analyzed || '—'}개`;
if (momPb) momPb.innerHTML = momSummaryLine;
const momCtx = $('mom_trade_context');
if (momCtx) {
fillTradePnLContext(momCtx, {
label: '백테 MOMENTUM',
totalBudget: Number(p.total_budget_krw || 0),
peakCum: Number(s.peak_cum_pnl || 0),
peakAt: s.peak_cum_at || '',
totalPnl: s.total_pnl,
});
}
setTxt('mom_total', (s.total_trades || 0) + '건');
setTxt('mom_winrate', (s.win_rate || 0) + '%');
colorPnl($('mom_winrate'), (s.win_rate || 0) - 50);
setTxt('mom_pnl', fmtKrw(s.total_pnl));
colorPnl($('mom_pnl'), s.total_pnl);
setTxt('mom_pf', (s.profit_factor || 0) >= 999 ? '∞' : String(s.profit_factor || 0));
colorPnl($('mom_pf'), (s.profit_factor || 0) - 1);
setTxt('mom_mdd', '-' + fmtWon(s.max_drawdown) + '원');
setTxt('mom_hold', (s.avg_hold_min || 0) + '분');
const momSign = v => (v > 0 ? '+' : '');
setTxt('mom_bot_pct', momSign(s.bot_pct || 0) + (s.bot_pct || 0) + '%');
colorPnl($('mom_bot_pct'), s.bot_pct || 0);
setTxt('mom_daily_avg_pct', momSign(s.daily_avg_pct || 0) + (s.daily_avg_pct || 0) + '%');
colorPnl($('mom_daily_avg_pct'), s.daily_avg_pct || 0);
if (s.budget_warning) {
if (momPb) momPb.innerHTML += `${meta.path || '?'} · `
+ `${meta.start || '?'} ~ ${meta.end || '?'} · `
+ `모드 ${meta.mode || '-'} · 조합 ${meta.tested_combos || '-'} / ${meta.cartesian_product || '-'}${meta.path || '?'} · `
+ `${meta.start || '?'} ~ ${meta.end || '?'} · TF ${meta.timeframe || 3}분 · `
+ `조합 ${meta.tested_combos || '-'} / ${meta.cartesian_product || '-'}${job.log_path}` : '') +
' · 전역 1잡 · 탭 이동 OK';
}
// 모든 전략 중지 버튼 동기화
Object.values(BTJOB_CFG).forEach(c => {
const b = c.stopBtn && $(c.stopBtn);
if (b) b.disabled = st !== 'running';
});
}
async function btJobLoadResult(jobId, strategy) {
const rr = await fetch('/api/backtest/job/result?job_id=' + encodeURIComponent(jobId));
const rd = await rr.json();
if (!rd.ok) throw new Error(rd.error || '결과 없음');
const strat = strategy || rd.strategy || 'tail';
const payload = rd.data || rd.raw || rd;
if (strat === 'scalp') {
renderBacktest(payload);
} else if (strat === 'breakout') {
renderBreakoutBacktest(payload);
} else if (strat === 'momentum') {
renderMomentumBacktest(payload);
} else {
if (payload && payload.equity) renderTailBacktest(payload);
else if (rd.ok) renderTailBacktestFromCli(rd);
}
}
async function btJobPollOnce() {
const requestedId = _btJobId;
if (!requestedId) return;
try {
const r = await fetch('/api/backtest/job/status?job_id=' + encodeURIComponent(requestedId));
if (requestedId !== _btJobId) return;
const j = await r.json();
if (!j.ok) {
if (r.status === 404 && requestedId === _btJobId) {
_btJobId = '';
localStorage.removeItem(BTJOB_LS_KEY);
localStorage.removeItem(BTJOB_LS_KEY_LEGACY);
btJobSetNav(null);
}
return;
}
const job = j.job || {};
const jid = String(job.job_id || requestedId);
if (jid !== _btJobId) return;
btJobSetNav(job);
const st = job.status;
if (st === 'done' && _btJobRenderedId !== jid) {
try {
await btJobLoadResult(jid, job.strategy);
if (jid !== _btJobId) return;
_btJobRenderedId = jid;
try { btJobGoTab(job.strategy); } catch (e2) {}
} catch (e) {
console.warn('btJobLoadResult', e);
}
}
} catch (e) { /* ignore */ }
}
function btJobStartPolling() {
if (_btJobPollTimer) clearInterval(_btJobPollTimer);
btJobPollOnce();
_btJobPollTimer = setInterval(btJobPollOnce, 2000);
}
function btJobCollectBody(strategy) {
const cfg = BTJOB_CFG[strategy];
if (!cfg) throw new Error('알 수 없는 전략: ' + strategy);
const univOn = !!(cfg.univId && $(cfg.univId)?.checked);
const body = {
strategy,
start: $(cfg.startId)?.value,
end: $(cfg.endId)?.value,
universe: univOn ? cfg.universeOn : cfg.universeOff,
universe_history_source: cfg.univSrcId ? univHistorySourceParam(cfg.univSrcId) : 'kiwoom',
orderbook_filter: (cfg.obId && $(cfg.obId)?.checked) ? 'on' : 'off',
env_timeline: cfg.envId ? envTimelineParam(cfg.envId) : 0,
};
if (cfg.tfId && $(cfg.tfId)) body.timeframe = $(cfg.tfId).value || 3;
if (cfg.tickId) body.backtest_use_tick_db = $(cfg.tickId)?.checked !== false ? 1 : 0;
return body;
}
async function btJobStart(strategy) {
const body = btJobCollectBody(strategy);
if (!body.start || !body.end) { alert('시작·종료일을 입력하세요'); return; }
if (!confirm(
`${BTJOB_CFG[strategy].label} 백그라운드 시작?\n${body.start}~${body.end}\n` +
`파라미터=현재 DB (+기간/유니버스)\n다른 전략 백테와 동시 실행 불가(전역 1잡).\n탭 이동 OK.`
)) return;
try {
const r = await fetch('/api/backtest/job/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const j = await r.json();
if (!j.ok) { alert('❌ ' + (j.error || '시작 실패')); return; }
_btJobId = j.job.job_id;
_btJobStrategy = strategy;
_btJobRenderedId = '';
localStorage.setItem(BTJOB_LS_KEY, _btJobId);
localStorage.removeItem(BTJOB_LS_KEY_LEGACY);
btJobClearUi(strategy, `실행중 ${body.start}~${body.end}`);
btJobSetNav(j.job);
btJobStartPolling();
} catch (e) {
alert('오류: ' + e);
}
}
async function btJobStop() {
if (!_btJobId) return;
if (!confirm('백그라운드 백테를 중지할까요? (프로세스 정리)')) return;
try {
const r = await fetch('/api/backtest/job/stop', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ job_id: _btJobId }),
});
const j = await r.json();
if (!j.ok) { alert(j.error || '중지 실패'); return; }
btJobSetNav(j.job);
} catch (e) { alert('오류: ' + e); }
}
// 하위호환
function runTailBacktestJob() { return btJobStart('tail'); }
function tlBtStop() { return btJobStop(); }
function tlBtGoTab() { return btJobGoTab('tail'); }
function renderTailBacktestFromCli(rd) {
// CLI JSON → 웹 /api/backtest/tail 과 동일 스키마 (equity/daily/reasons/win_trades)
// 없으면 renderTailBacktest 가 equity.map 에서 터져 승패·차트·거래내역이 안 나옴
const summary = rd.summary || (rd.data && rd.data.summary) || {};
const trades = rd.trades || (rd.data && rd.data.trades) || [];
const meta = rd.meta || (rd.data && rd.data.meta) || {};
const pu = rd.params_used || (rd.data && rd.data.params_used) || {};
const sorted = trades.slice().sort((a, b) =>
String(a.exit_time || a.sell_time || '').localeCompare(String(b.exit_time || b.sell_time || ''))
);
let cum = 0;
let peak = 0;
let peakAt = '';
let mdd = 0;
const equity = [];
const dailyMap = {};
const reasons = {};
for (const t of sorted) {
const pnl = Number(t.pnl ?? t.realized_pnl ?? 0) || 0;
cum += pnl;
if (cum > peak) {
peak = cum;
peakAt = String(t.exit_time || t.sell_time || '');
}
const dd = peak - cum;
if (dd > mdd) mdd = dd;
const et = String(t.exit_time || t.sell_time || '').replace(/[-: T]/g, '');
const day = et.length >= 8
? `${et.slice(0, 4)}-${et.slice(4, 6)}-${et.slice(6, 8)}`
: (et || '—');
equity.push({ date: day, cum_pnl: Math.round(cum), pnl: Math.round(pnl) });
dailyMap[day] = (dailyMap[day] || 0) + pnl;
const rk = String(t.reason || t.sell_reason || '기타');
reasons[rk] = (reasons[rk] || 0) + 1;
}
const daily = Object.keys(dailyMap).sort().map(d => ({
date: d,
pnl: Math.round(dailyMap[d]),
}));
const winsN = Number(
summary.win_trades != null ? summary.win_trades
: (summary.wins != null ? summary.wins
: trades.filter(t => Number(t.pnl ?? t.realized_pnl ?? 0) > 0).length)
);
const lossN = Number(
summary.loss_trades != null ? summary.loss_trades
: (summary.losses != null ? summary.losses
: Math.max(0, trades.length - winsN))
);
const fake = {
summary: {
total_trades: summary.total_trades != null ? summary.total_trades : trades.length,
win_trades: winsN,
loss_trades: lossN,
win_rate: summary.win_rate || 0,
total_pnl: summary.total_pnl || 0,
profit_factor: summary.profit_factor != null ? summary.profit_factor : (summary.pf || 0),
max_drawdown: summary.max_drawdown != null ? summary.max_drawdown
: (summary.mdd != null ? summary.mdd : Math.round(mdd)),
avg_hold_min: summary.avg_hold_min || 0,
bot_pct: summary.bot_pct || 0,
daily_avg_pct: summary.daily_avg_pct || 0,
peak_cum_pnl: summary.peak_cum_pnl != null ? summary.peak_cum_pnl : Math.round(peak),
peak_cum_at: summary.peak_cum_at || peakAt,
tick_backtest: summary.tick_backtest || meta.tick_backtest,
backtest_buy_source: summary.backtest_buy_source || meta.backtest_buy_source,
tick_entry_sources: summary.tick_entry_sources || meta.tick_entry_sources,
skip_stats: summary.skip_stats || meta.skip_stats || {},
},
params: {
universe_source: meta.universe_source || rd.universe_source || 'history',
timeframe: rd.timeframe || pu.timeframe || 3,
slot_money: pu.slot_money || 0,
max_stocks: pu.max_stocks || 0,
total_budget_krw: pu.total_budget_krw || 0,
codes_analyzed: meta.codes_analyzed || meta.n_codes
|| (Array.isArray(meta.codes) ? meta.codes.length : (trades.length || 0)),
min_drop_rate: (function () {
const v = Number(pu.min_drop_rate);
if (!Number.isFinite(v)) return pu.min_drop_rate ?? '';
// 엔진 비율(0.055) → UI % (5.5)
return (v > 0 && v <= 1) ? (v * 100) : v;
})(),
min_recovery_ratio: (function () {
const v = Number(pu.min_recovery_ratio);
if (!Number.isFinite(v)) return pu.min_recovery_ratio ?? '';
return (v > 0 && v <= 1) ? (v * 100) : v;
})(),
tail_ratio_min: pu.tail_ratio_min ?? '',
sl_pct: (function () {
const v = Number(pu.sl_pct);
if (!Number.isFinite(v)) return pu.sl_pct ?? '';
return (v > 0 && v <= 1) ? (v * 100) : v;
})(),
tp_pct: (function () {
const v = Number(pu.tp_pct);
if (!Number.isFinite(v)) return pu.tp_pct ?? '';
return (v > 0 && v <= 1) ? (v * 100) : v;
})(),
shoulder_min_high: (function () {
const v = Number(pu.shoulder_min_high ?? pu.shoulder_min_high_pct);
if (!Number.isFinite(v)) return '';
return (v > 0 && v <= 1) ? (v * 100) : v;
})(),
shoulder_cut_pct: (function () {
const v = Number(pu.shoulder_cut_pct);
if (!Number.isFinite(v)) return '';
return (v > 0 && v <= 1) ? (v * 100) : v;
})(),
trail_pct: pu.trail_pct ?? 0,
trail_arm_pct: pu.trail_arm_pct ?? 0,
max_loss_krw: pu.max_loss_krw,
rsi_period: pu.rsi_period ?? '',
rsi_threshold: pu.rsi_threshold ?? '',
cooldown_min: pu.cooldown_min ?? '',
time_window: pu.time_window || (
(pu.time_start != null && pu.time_end != null)
? `${pu.time_start}-${pu.time_end}` : ''
),
max_daily: pu.max_daily ?? '',
entry_mode: pu.entry_mode,
limit_atr_mult: pu.limit_atr_mult,
limit_anchor: pu.limit_anchor,
limit_valid_bars: pu.limit_valid_bars,
limit_fill_slip_pct: pu.limit_fill_slip_pct,
},
equity,
daily,
reasons,
trades,
};
try {
renderTailBacktest(fake);
} catch (e) {
console.warn('renderTailBacktestFromCli', e);
alert(
`백그라운드 완료(화면 렌더 실패)\n거래 ${summary.total_trades} · WR ${summary.win_rate}% · PnL ${summary.total_pnl}\n` +
(rd.result_json || '') + `\n${e}`
);
}
}
// 페이지 로드 시 이전 잡 폴링 재개 (탭 무관)
if (_btJobId) {
setTimeout(() => btJobStartPolling(), 800);
}
function renderTailBacktest(d) {
const s = d.summary;
const p = d.params;
$('tl_params_bar').style.display = '';
const tlUniverseLabel = formatUniverseLabel(p);
const tlSummaryLine =
`${tlUniverseLabel} ` +
(p.timeframe != null ? `${p.timeframe}분봉 ` : '') +
`낙폭≥${p.min_drop_rate}% | 회복률≥${p.min_recovery_ratio}% | 꼬리/몸통≥${p.tail_ratio_min} | ` +
`손절-${p.sl_pct}% 익절+${p.tp_pct}% | 어깨컷(${p.shoulder_min_high}%발동/${p.shoulder_cut_pct}%하락)` +
(Number(p.trail_pct) > 0 ? ` | 보조트레일${p.trail_pct}%` + (Number(p.trail_arm_pct) > 0 ? `(무장${p.trail_arm_pct}%)` : '') : '') + ` | ` +
(p.max_loss_krw != null ? `최대손실컷 ${Number(p.max_loss_krw).toLocaleString()}원 | ` : '') +
`1회투자 ${Number(p.slot_money||0).toLocaleString()}원 | 동시${p.max_stocks||3}종 | 총한도 ${Number(p.total_budget_krw||0).toLocaleString()}원 | ` +
`RSI(${p.rsi_period})과열<${p.rsi_threshold} | 쿨다운${p.cooldown_min}분 | ${p.time_window || '930-1500'} | 일${p.max_daily || 3}회/종목 | 종목수 ${p.codes_analyzed}개 | 시각순 포트폴리오` +
formatLimitEntryLabel(p);
$('tl_params_bar').innerHTML = tlSummaryLine;
const tlCtx = $('tl_trade_context');
if (tlCtx) {
fillTradePnLContext(tlCtx, {
label: '백테 SHORT(꼬리)',
totalBudget: Number(p.total_budget_krw || 0),
peakCum: Number(s.peak_cum_pnl || 0),
peakAt: s.peak_cum_at || '',
totalPnl: s.total_pnl,
});
}
$('tl_total').textContent = (s.total_trades||0) + '건';
$('tl_winrate').textContent = (s.win_rate||0) + '%';
colorPnl($('tl_winrate'), (s.win_rate||0) - 50);
$('tl_pnl').textContent = fmtKrw(s.total_pnl);
colorPnl($('tl_pnl'), s.total_pnl);
$('tl_pf').textContent = ((s.profit_factor ?? s.pf) || 0) >= 999 ? '∞' : ((s.profit_factor ?? s.pf) || 0);
colorPnl($('tl_pf'), ((s.profit_factor ?? s.pf) || 0) - 1);
$('tl_mdd').textContent = '-' + fmtWon(s.max_drawdown ?? s.mdd ?? 0) + '원';
$('tl_hold').textContent = (s.avg_hold_min||0) + '분';
const tlSign = v => (v > 0 ? '+' : '');
$('tl_bot_pct').textContent = tlSign(s.bot_pct || 0) + (s.bot_pct || 0) + '%';
colorPnl($('tl_bot_pct'), s.bot_pct || 0);
$('tl_daily_avg_pct').textContent = tlSign(s.daily_avg_pct || 0) + (s.daily_avg_pct || 0) + '%';
colorPnl($('tl_daily_avg_pct'), s.daily_avg_pct || 0);
if (s.universe_warning) {
$('tl_params_bar').innerHTML += `${lcEsc(row.strategy_id)}
${lcEsc(f.table)}` : '';
const keyTag = f.key
? `${lcEsc(f.key)}`
: '';
const isDanger = !!(f.danger) || /FALLBACK_OHLC/i.test(String(f.key || ''));
const wrapCls = isDanger ? 'lc-field lc-field-danger' : 'lc-field';
if (f.type === 'bool') {
const chk = f.value ? 'checked' : '';
return `${rec.tiers || ''}` +
`${jid}