${Number(row.return_pct || 0).toFixed(2)}% ·
${lcEsc(sidMatch[1])} `
: '';
toggles += `
${lcEsc(f.label)} ${sidTag}
`;
});
root.innerHTML = `
${lcEsc(g.title || '전략 ON/OFF')}
${g.hint ? `
${lcEsc(g.hint)}
` : ''}
💾 이 섹션 저장
${toggles}
`;
}
function lcFieldInput(f) {
const id = 'lc_' + f.key;
const hint = f.hint ? `
${lcEsc(f.hint)}
` : '';
const tbl = f.table ? `
${lcEsc(f.table)}` : '';
const keyTag = f.key
? `
${lcEsc(f.key)}`
: '';
const isDanger = !!(f.danger) || /FALLBACK_OHLC/i.test(String(f.key || ''));
const wrapCls = isDanger ? 'lc-field lc-field-danger' : 'lc-field';
if (f.type === 'bool') {
const chk = f.value ? 'checked' : '';
return `
${lcEsc(f.label)} ${keyTag} ${tbl}${hint}
`;
}
if (f.type === 'text') {
const val = f.value != null ? f.value : '';
return `
${lcEsc(f.label)} ${keyTag} ${tbl}
${hint}
`;
}
if (f.type === 'select' && Array.isArray(f.choices)) {
const val = f.value != null ? String(f.value) : '';
const valNorm = val.toLowerCase();
let matched = false;
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 || cv.toLowerCase() === valNorm) ? ' selected' : '';
if (sel) matched = true;
return `
${lcEsc(cl)} `;
}).join('');
const extra = (!matched && val)
? `
${lcEsc(val)} (현재·목록외) `
: '';
return `
${lcEsc(f.label)} ${keyTag} ${tbl}
${extra}${opts}
${hint}
`;
}
const step = f.type === 'int' ? '1' : '0.01';
const val = f.value != null ? f.value : '';
return `
${lcEsc(f.label)} ${keyTag} ${tbl}
${hint}
`;
}
/** env 키 → 전략 구역 ID (카테고리 안 시각 구분용) */
function lcInferStrategy(key) {
const k = String(key || '').toUpperCase();
const m = k.match(/^STRATEGY_([A-Z0-9_]+)_ENABLED$/);
if (m) return m[1];
// 긴 prefix 우선
const pairs = [
['RANGE_BREAK_', 'RANGE_BREAK'],
['UPDOWN_', 'UPDOW'],
['UPDOW_', 'UPDOW'],
['BREAKOUT_', 'BREAKOUT'],
['MOMENTUM_', 'MOMENTUM'],
['SCALP_', 'SCALP'],
['DBBAND_', 'DBBAND'],
['TAIL_', 'SHORT'],
['SHORT_', 'SHORT'],
];
for (let i = 0; i < pairs.length; i++) {
if (k.startsWith(pairs[i][0])) return pairs[i][1];
}
return 'COMMON';
}
/** 공통 섹션용 — 주제(호가/프로그램/휩쏘…)로 한 번 더 묶기 */
function lcInferTopic(key) {
const k = String(key || '').toUpperCase();
if (k.includes('ORDERBOOK') || k.includes('SPREAD')) return 'orderbook';
if (k.includes('PROGRAM')) return 'program';
if (k.includes('WHIPSAW')) return 'whipsaw';
if (k.startsWith('WS_TRIGGER') || k.includes('TRIGGER_EVAL')) return 'trigger_master';
if (k.includes('PENDING') || k.includes('FILL') || k.includes('ORDER_') || k.includes('IOC')
|| k.includes('DEDUP') || k.includes('SLIP') || k.includes('STRICT_FILL')
|| k.includes('CANCEL_PARTIAL') || k.includes('DUPLICATE_ORDER')) return 'fill';
if (k.includes('BACKTEST') || k.includes('PARAM_SEARCH') || k.includes('POLL_MS')
|| k.includes('TICK_')) return 'backtest';
if (k.startsWith('KIS_') || k.includes('BALANCE_MAX') || k.includes('INTERVAL_SEC')) return 'rest';
if (k.startsWith('SCAN_') || k.startsWith('STRATEGY_LOOP')) return 'scan';
if (k.includes('ORPHAN') || k.includes('GHOST') || k.includes('MANUAL_HOLD')
|| k.includes('BULK_SELL') || k.includes('DRIFT')) return 'portfolio';
if (k.includes('DAILY_PROFIT') || k.includes('DAILY_STOP') || k.includes('CONSECUTIVE_LOSS')
|| k.includes('USE_RISK')) return 'risk';
return 'misc';
}
const LC_TOPIC_ORDER = [
'trigger_master', 'orderbook', 'program', 'whipsaw',
'fill', 'backtest', 'rest', 'scan', 'risk', 'portfolio', 'misc',
];
const LC_TOPIC_LABELS = {
trigger_master: 'TRIGGER 마스터',
orderbook: '호가',
program: '프로그램',
whipsaw: '휩쏘',
fill: '체결·주문',
backtest: '백테·틱청산',
rest: 'REST 유량',
scan: '스캔 루프',
risk: '리스크',
portfolio: '보유·고아',
misc: '기타',
};
const LC_STRAT_ORDER = [
'COMMON', 'SHORT', 'MOMENTUM', 'BREAKOUT', 'UPDOW',
'SCALP', 'RANGE_BREAK', 'DBBAND', 'OTHER',
];
const LC_STRAT_LABELS = {
COMMON: '공통',
SHORT: '꼬리잡기',
MOMENTUM: '모멘텀',
BREAKOUT: '돌파',
UPDOW: 'UPDOWN 박스',
SCALP: '스캘핑',
RANGE_BREAK: '박스권돌파',
DBBAND: '더블BB',
OTHER: '기타',
};
/** 한 카테고리 fields → 전략별 구역 배열 */
function lcPartitionFieldsByStrategy(fields) {
const buckets = {};
(fields || []).forEach(f => {
let sid = lcInferStrategy(f.key);
if (LC_STRAT_ORDER.indexOf(sid) < 0) sid = 'OTHER';
if (!buckets[sid]) buckets[sid] = [];
buckets[sid].push(f);
});
return LC_STRAT_ORDER
.filter(sid => buckets[sid] && buckets[sid].length)
.map(sid => ({
sid,
label: LC_STRAT_LABELS[sid] || sid,
fields: buckets[sid],
}));
}
/** 공통 필드만 있을 때 주제별로 재분할 (2구역 이상이면 사용) */
function lcPartitionFieldsByTopic(fields) {
const buckets = {};
(fields || []).forEach(f => {
const tid = lcInferTopic(f.key);
if (!buckets[tid]) buckets[tid] = [];
buckets[tid].push(f);
});
return LC_TOPIC_ORDER
.filter(tid => buckets[tid] && buckets[tid].length)
.map(tid => ({
sid: 'topic',
topicId: tid,
label: LC_TOPIC_LABELS[tid] || tid,
fields: buckets[tid],
}));
}
function lcRenderFieldGrid(fields) {
let html = '
';
(fields || []).forEach(f => { html += lcFieldInput(f); });
html += '
';
return html;
}
function lcRenderBlock(p) {
const cls = p.sid === 'topic'
? 'lc-strat-block lc-strat-topic'
: ('lc-strat-block lc-strat-' + String(p.sid).toLowerCase());
const sidTag = (p.sid === 'COMMON' || p.sid === 'topic')
? ''
: `
${lcEsc(p.sid)} `;
return `
${lcEsc(p.label)} ${sidTag}
${lcRenderFieldGrid(p.fields)}
`;
}
function lcRenderGroups(groups) {
const root = $('lc_groups');
if (!root) return;
const switchGroup = (groups || []).find(g => g.id === 'strategy_switch');
lcRenderStrategySwitch(switchGroup || null);
let html = '';
(groups || []).forEach(g => {
if (g.id === 'strategy_switch') return;
const parts = lcPartitionFieldsByStrategy(g.fields || []);
const splitStrat = parts.length > 1;
// 전략이 하나뿐(공통만)이면 주제로 한 번 더 나눔
let renderParts = parts;
let useBlocks = splitStrat;
if (!splitStrat && parts.length === 1) {
const topics = lcPartitionFieldsByTopic(parts[0].fields);
if (topics.length > 1) {
renderParts = topics;
useBlocks = true;
}
}
html += `
${lcEsc(g.title)}
${g.hint ? `
${lcEsc(g.hint)}
` : ''}
💾 이 섹션 저장
`;
if (useBlocks) {
html += '
';
renderParts.forEach(p => { html += lcRenderBlock(p); });
html += '
';
} else {
html += lcRenderFieldGrid((renderParts[0] && renderParts[0].fields) || g.fields || []);
}
html += '
';
});
root.innerHTML = html;
}
function lcCollectPatch(scopeGroupId) {
const patch = {};
const root = scopeGroupId
? document.querySelector(`.lc-group[data-lc-group="${scopeGroupId}"]`)
: $('tab-liveconfig');
if (!root) return patch;
root.querySelectorAll('[data-lc-key]').forEach(el => {
const key = el.dataset.lcKey;
const typ = el.dataset.lcType || 'text';
if (typ === 'bool') {
patch[key] = el.checked;
} else {
patch[key] = el.value;
}
});
return patch;
}
async function lcLoad(silent) {
lcInitDate();
const day = $('lc_date').value;
if (!silent) showSpinner(true);
try {
const groupsReady = $('lc_groups') && $('lc_groups').children.length;
const q = '/api/live_config?date=' + encodeURIComponent(day)
+ accountQueryParam('lc_account')
+ (silent && groupsReady ? '&status_only=1' : '');
const r = await fetch(q);
const d = await r.json();
if (!d.ok) {
if (!silent) alert(d.error || '운영 설정 조회 실패');
return;
}
_lcData = d;
const st = d.status || {};
$('lc_meta').textContent =
'기준일 ' + d.date
+ (st.account_label ? ' · 계좌 ' + st.account_label : '')
+ ' · 갱신 ' + (d.as_of || '');
lcRenderStatus(d.status);
if (!silent || !groupsReady) {
if (d.groups) lcRenderGroups(d.groups);
}
} catch (e) {
if (!silent) alert('운영 설정 조회 오류: ' + e);
} finally {
if (!silent) showSpinner(false);
}
}
async function lcSavePatch(patch, label) {
if (!patch || !Object.keys(patch).length) {
alert('저장할 값이 없습니다.');
return;
}
if (!confirm('💾 ' + (label || '운영 설정') + '을(를) DB에 저장할까요?\n봇은 재시작 없이 다음 루프부터 반영됩니다.')) return;
showSpinner(true);
const resEl = $('lc_save_result');
try {
const r = await fetch('/api/live_config/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ patch }),
});
const d = await r.json();
if (!d.ok) {
alert(d.error || '저장 실패');
return;
}
const tbl = d.saved_by_table
? Object.entries(d.saved_by_table).map(([t, ks]) => t + ': ' + ks.length + '키').join(' · ')
: '';
resEl.innerHTML = `
✅ 저장 완료 (env_id=${d.env_id}) — ${tbl} `;
await lcLoad(false);
} catch (e) {
alert('저장 오류: ' + e);
} finally {
showSpinner(false);
}
}
function lcSaveGroup(groupId) {
lcSavePatch(lcCollectPatch(groupId), '섹션 ' + groupId);
}
function lcSaveAll() {
lcSavePatch(lcCollectPatch(null), '전체 운영 설정');
}
// ════════════════════════════════════════════════════════════════
// 박스엔진 UPDOWN (kis_trader/engine/updown_box)
// watchlist(sticky) 관리 + 박스 백테 + 글로벌 파라서치
// ════════════════════════════════════════════════════════════════
// 입력란 id ↔ 박스 cfg 키 매핑 (env UPDOWN_BOX_* 와 1:1)
const UBX_PARAM_FIELDS = [
'lookback_bars', 'range_max_pct', 'bb_bw_max', 'ma_slope_tol_pct',
'near_low_pct', 'tp_pct', 'stop_loss_pct', 'breach_buffer_pct',
'panic_from_high_pct', 'shoulder_min_high_pct', 'shoulder_cut_pct', 'max_hold_bars',
'breakout_follow',
];
function ubxOnTabShow() {
// 날짜 기본값 — 주말/휴장이면 이전 장운영일
const end = $('ubx_end'), start = $('ubx_start');
if (end && !end.value) end.value = kstClampToPrevTradingDayIso(kstTodayParts().iso, _krHolidays);
else if (end && end.value) end.value = kstClampToPrevTradingDayIso(end.value, _krHolidays);
if (start && !start.value) {
const endIso = (end && end.value) ? end.value : kstClampToPrevTradingDayIso(kstTodayParts().iso, _krHolidays);
start.value = kstClampToPrevTradingDayIso(kstAddDaysIso(endIso, -180), _krHolidays);
} else if (start && start.value) {
start.value = kstClampToPrevTradingDayIso(start.value, _krHolidays);
}
ubxLoadCfg();
ubxLoadWatch();
ubxLoadStockList();
}
/** 종목 셀렉트(datalist) 채우기 — watchlist + 종목별 핀 등록 종목 */
function ubxLoadStockList() {
fetch('/api/updown_box/stock_list')
.then(r => r.json())
.then(d => {
const dl = $('ubx_stock_options');
if (!dl) return;
const items = d.items || [];
dl.innerHTML = items.map(it => {
const pin = it.n_overrides > 0 ? ' ★핀' : '';
const st = it.status ? ` (${it.status})` : '';
return `
${it.name || it.code}${st}${pin} `;
}).join('');
})
.catch(e => console.warn('[ubx stock_list]', e));
}
/** 종목 선택 시 그 종목의 박스 파라미터(있으면 핀, 없으면 글로벌) → 입력란 */
function ubxLoadStockCfg(code) {
code = String(code || '').trim();
const badge = $('ubx_stock_cfg_badge');
if (!/^\d{6}$/.test(code)) { if (badge) badge.innerHTML = ''; return; }
fetch('/api/updown_box/stock_cfg?code=' + encodeURIComponent(code))
.then(r => r.json())
.then(d => {
if (d.error) { if (badge) badge.innerHTML = ''; return; }
const eff = d.effective || {};
UBX_PARAM_FIELDS.forEach(k => {
const el = $('ubx_' + k);
if (el && eff[k] != null) el.value = eff[k];
});
const rt = $('ubx_ratchet_tiers');
if (rt) rt.value = (eff.ratchet_tiers != null ? eff.ratchet_tiers : '');
if (typeof ubxSyncRatchetShoulderColors === 'function') ubxSyncRatchetShoulderColors();
const n = Object.keys(d.overrides || {}).length;
if (badge) {
badge.innerHTML = n > 0
? `
★ 종목별 핀 ${n}개 적용중 (저장값 로드) `
: `
글로벌 사용중 (핀 없음) `;
}
})
.catch(e => console.warn('[ubx stock_cfg]', e));
}
/** 현재 입력값을 [종목코드] 종목별 파라미터로 고정 저장(핀) */
function ubxSaveStockCfg() {
const code = ($('ubx_code').value || '').trim();
if (!/^\d{6}$/.test(code)) { alert('종목코드(6자리)를 선택/입력하세요'); return; }
const overrides = {};
UBX_PARAM_FIELDS.forEach(k => {
const el = $('ubx_' + k);
if (el && el.value !== '' && el.value != null) overrides[k] = el.value;
});
const rt = $('ubx_ratchet_tiers');
if (rt) overrides.ratchet_tiers = String(rt.value || '').trim(); // '' = 래칫 OFF
if (!confirm(code + ' 종목을 이 파라미터로 고정(핀)할까요?\n실매·백테 모두 이 종목엔 글로벌 대신 이 값이 적용됩니다.')) return;
fetch('/api/updown_box/stock_cfg', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, name: '', overrides }),
})
.then(r => r.json())
.then(d => {
if (!d.ok) { alert('저장 실패: ' + (d.error || '')); return; }
alert('✅ ' + code + ' 종목별 핀 저장 (' + (d.saved_keys || []).length + '개 파라미터)');
ubxLoadStockList(); ubxLoadStockCfg(code);
})
.catch(e => alert('오류: ' + e));
}
/** 이 종목의 핀 해제 → 글로벌 복귀 */
function ubxClearStockCfg() {
const code = ($('ubx_code').value || '').trim();
if (!/^\d{6}$/.test(code)) { alert('종목코드(6자리)를 선택/입력하세요'); return; }
if (!confirm(code + ' 종목별 핀을 해제하고 글로벌로 복귀할까요?')) return;
fetch('/api/updown_box/stock_cfg', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, clear: '1' }),
})
.then(r => r.json())
.then(d => {
if (!d.ok) { alert('해제 실패: ' + (d.error || '')); return; }
alert('↩ ' + code + ' 글로벌로 복귀');
ubxLoadStockList(); ubxLoadCfg(); ubxLoadStockCfg(code);
})
.catch(e => alert('오류: ' + e));
}
/** 운영설정 UPDOWN_BOX_* 현재값 → 입력란 + watchlist 통계 */
function ubxLoadCfg() {
fetch('/api/updown_box/config')
.then(r => r.json())
.then(d => {
if (!d.ok) { console.warn('[ubx cfg]', d.error); return; }
const c = d.cfg || {};
UBX_PARAM_FIELDS.forEach(k => {
const el = $('ubx_' + k);
if (el && c[k] != null) el.value = c[k];
});
const rt = $('ubx_ratchet_tiers');
if (rt) rt.value = (c.ratchet_tiers != null ? c.ratchet_tiers : '');
if (typeof ubxSyncRatchetShoulderColors === 'function') ubxSyncRatchetShoulderColors();
if ($('ubx_tf') && d.scan_tf_min) $('ubx_tf').value = String(d.scan_tf_min);
const st = $('ubx_watch_stat');
if (st) st.textContent = `— active ${d.watch_active} / max ${d.watch_max}`;
})
.catch(e => console.warn('[ubx cfg]', e));
}
/** watchlist 목록 표 */
function ubxLoadWatch() {
const activeOnly = $('ubx_watch_active_only') && $('ubx_watch_active_only').checked;
fetch('/api/updown_box/watchlist?active=' + (activeOnly ? '1' : '0'))
.then(r => r.json())
.then(d => {
const tb = $('ubx_watch_tbody');
if (!tb) return;
if (!d.ok) { tb.innerHTML = `
${d.error || '오류'} `; return; }
const rows = d.rows || [];
if (!rows.length) { tb.innerHTML = '
등록된 관심종목 없음 (SCAN 또는 수동추가) '; return; }
tb.innerHTML = rows.map(r => {
const stColor = r.status === 'active' ? '#3fb950' : '#8b949e';
const sig = r.last_signal_at ? String(r.last_signal_at).slice(0, 16) : '-';
const added = r.added_at ? String(r.added_at).slice(0, 10) : '-';
return `
${r.code}
${r.name || ''}
${r.status}
${r.source || ''}
${Number(r.box_low || 0).toLocaleString()}
${Number(r.box_high || 0).toLocaleString()}
${Number(r.box_score || 0).toFixed(1)}
${added}
${sig}
수정
삭제
`;
}).join('');
})
.catch(e => { const tb = $('ubx_watch_tbody'); if (tb) tb.innerHTML = `
${e} `; });
}
function ubxAddWatch() {
const code = ($('ubx_add_code').value || '').trim();
if (!/^\d{6}$/.test(code)) { alert('6자리 종목코드를 입력하세요'); return; }
const body = {
code,
name: ($('ubx_add_name').value || '').trim(),
box_low: parseFloat($('ubx_add_low').value || '0') || 0,
box_high: parseFloat($('ubx_add_high').value || '0') || 0,
};
fetch('/api/updown_box/watchlist/add', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
})
.then(r => r.json())
.then(d => {
if (!d.ok) { alert('추가 실패: ' + (d.error || '한도초과/보유중')); return; }
$('ubx_add_code').value = ''; $('ubx_add_name').value = '';
$('ubx_add_low').value = ''; $('ubx_add_high').value = '';
ubxLoadWatch(); ubxLoadCfg();
})
.catch(e => alert('오류: ' + e));
}
/** 수정: 행 값을 입력폼에 채워 넣음 → 고친 뒤 '추가/수정' 누르면 upsert 갱신 */
function ubxEditWatch(code, name, low, high) {
$('ubx_add_code').value = code || '';
$('ubx_add_name').value = name || '';
$('ubx_add_low').value = low || '';
$('ubx_add_high').value = high || '';
const c = $('ubx_add_code');
if (c) { c.scrollIntoView({ behavior: 'smooth', block: 'center' }); c.focus(); }
}
function ubxRemoveWatch(code) {
if (!confirm(code + ' 관심종목을 삭제할까요?')) return;
fetch('/api/updown_box/watchlist/remove', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code }),
})
.then(r => r.json())
.then(d => { if (!d.ok) alert('삭제 실패: ' + (d.error || '')); ubxLoadWatch(); ubxLoadCfg(); })
.catch(e => alert('오류: ' + e));
}
/** 입력란 → 백테/파라서치 쿼리 (빈값 제외, env 오버라이드) */
function ubxCollectParamQs(qs) {
UBX_PARAM_FIELDS.forEach(k => {
const el = $('ubx_' + k);
if (el && el.value !== '' && el.value != null) qs.append(k, String(el.value));
});
const rt = $('ubx_ratchet_tiers'); // 다단 래칫(문자열) — 숫자 필드와 별도
if (rt && rt.value != null && String(rt.value).trim() !== '') {
qs.append('ratchet_tiers', String(rt.value).trim());
}
}
// 래칫/어깨컷 입력칸 색상 토글 — TAIL(tlSyncRatchetShoulderColors)과 동일.
// 다단 래칫 값이 있으면 단일 어깨컷(어깨발동·어깨폭)은 무시됨 → 빨강 표시.
function ubxSyncRatchetShoulderColors() {
const r = $('ubx_ratchet_tiers');
if (!r) return;
const smin = $('ubx_shoulder_min_high_pct');
const scut = $('ubx_shoulder_cut_pct');
const ratchetOn = !!(r.value && String(r.value).trim());
const RED = '#f85149';
const paint = (el, on) => {
if (!el) return;
if (on) {
el.style.setProperty('border-color', RED, 'important');
el.style.setProperty('background-color', 'rgba(248,81,73,0.12)', 'important');
el.style.setProperty('box-shadow', '0 0 0 1px ' + RED, 'important');
} else {
el.style.removeProperty('border-color');
el.style.removeProperty('background-color');
el.style.removeProperty('box-shadow');
}
};
// 래칫 ON → 어깨컷 빨강(무시됨) / 래칫 OFF → 래칫 빨강(OFF, 어깨컷이 트레일)
paint(smin, ratchetOn);
paint(scut, ratchetOn);
paint(r, !ratchetOn);
}
function ubxRunBacktest() {
const code = ($('ubx_code').value || '').trim();
if (!/^\d{6}$/.test(code)) { alert('종목코드(6자리)를 입력하세요'); return; }
const qs = new URLSearchParams({
code, tf: $('ubx_tf').value || '15',
start: $('ubx_start').value || '', end: $('ubx_end').value || '',
});
ubxCollectParamQs(qs);
$('ubx_status').textContent = '백테 실행 중…';
showSpinner(true);
fetch('/api/updown_box/backtest?' + qs.toString())
.then(r => r.json())
.then(d => {
showSpinner(false);
if (d.error) { $('ubx_status').textContent = ''; alert(d.error); return; }
$('ubx_status').textContent = '';
$('ubx_ps_card').style.display = 'none';
ubxRenderSummary(d);
ubxRenderTrades(d.trades || [], `${d.code} · ${d.tf}분 · ${d.candle_count}봉`, { code: d.code, name: d.name || '' });
})
.catch(e => { showSpinner(false); $('ubx_status').textContent = ''; alert('오류: ' + e); });
}
function ubxRenderSummary(d) {
const card = $('ubx_summary_card'), box = $('ubx_summary');
if (!card || !box) return;
const bn = d.box_now || {};
const boxBadge = bn.is_box
? `
박스권 ✔ (범위 ${Number(bn.range_pct || 0).toFixed(1)}% · 밴드폭 ${bn.bb_bw != null ? Number(bn.bb_bw).toFixed(1) : '-'}% · 기울기 ${bn.ma_slope != null ? Number(bn.ma_slope).toFixed(2) : '-'}%)`
: `
비박스 (${bn.reason || ''})`;
const bh = (d.buy_hold_pct != null) ? Number(d.buy_hold_pct) : null;
const alpha = (d.alpha_pct != null) ? Number(d.alpha_pct) : null;
box.innerHTML = `
거래 ${d.n_trades}
봇 총손익 ${d.total_pnl}%
PF ${d.pf}
승률 ${d.win_rate}%
${bh != null ? `
단순보유(B&H) ${bh.toFixed(2)}%
` : ''}
${alpha != null ? `
초과수익(α) ${alpha >= 0 ? '+' : ''}${alpha.toFixed(2)}%
` : ''}
${alpha != null ? `
${alpha >= 0 ? '✔ 봇이 단순보유를 이김' : '✘ 단순보유가 더 나음 — 이 종목엔 박스전략 부적합'}
` : ''}
현재 박스상태(최근봉): ${boxBadge}
${bn.box_low ? ` · 하단 ${Number(bn.box_low).toLocaleString()} / 상단 ${Number(bn.box_high).toLocaleString()}` : ''}
`;
$('ubx_summary_sub').textContent = `${d.code} · ${d.tf}분`;
card.style.display = '';
}
function ubxRenderTrades(trades, sub, meta) {
const card = $('ubx_trades_card');
if (!card) return;
meta = meta || {};
// 박스 백테 거래(run_backtest_box) → 공통 거래목록 스키마로 변환.
// 손익(원)은 100만원 슬롯 가정으로 환산(수익률%는 실제 백테값 그대로).
const mapped = (trades || []).map(t => {
const ep = Number(t.buy || 0), xp = Number(t.sell || 0);
const qty = ep > 0 ? Math.max(1, Math.floor(1000000 / ep)) : 0;
return {
code: meta.code || '',
name: meta.name || '',
buy_price: ep,
sell_price: xp,
entry_time: t.entry_time,
exit_time: t.exit_time,
profit_rate: t.pnl_pct,
pnl: Math.round((xp - ep) * qty),
qty,
sell_reason: t.reason,
hold_min: t.bars_held,
};
});
// peak / 최종 누적 (매도순)
let peak = 0, peakAt = '', cum = 0;
const ordered = [...mapped].sort((a, b) => tradeExitSortKey(a).localeCompare(tradeExitSortKey(b)));
for (const t of ordered) {
cum += Number(t.pnl || 0);
if (cum > peak) { peak = cum; peakAt = t.exit_time || ''; }
}
renderVirtualTrades('ubx_trades_tbody', mapped, {
meta,
showCumulative: true,
totalBudget: 1000000,
});
fillTradePnLContext('ubx_trade_context', {
label: '백테 UPDOWN_BOX',
totalBudget: 1000000,
peakCum: peak,
peakAt,
totalPnl: cum,
});
const subEl = $('ubx_trades_sub');
if (subEl) subEl.textContent = sub || '';
card.style.display = '';
}
function ubxRunParamSearch() {
const qs = new URLSearchParams({
tf: $('ubx_tf').value || '15',
start: $('ubx_start').value || '', end: $('ubx_end').value || '',
mode: $('ubx_mode').value || 'fast',
min_trades: $('ubx_min_trades').value || '3',
});
if ($('ubx_from_watchlist') && $('ubx_from_watchlist').checked) {
qs.append('from_watchlist', '1');
} else {
const codes = ($('ubx_codes').value || '').trim() || ($('ubx_code').value || '').trim();
if (!codes) { alert('탐색 종목(codes) 또는 watchlist 사용을 지정하세요'); return; }
qs.append('codes', codes);
}
$('ubx_status').textContent = '파라미터 탐색 중… (조합 수에 따라 수십 초)';
showSpinner(true);
fetch('/api/updown_box/param_search?' + qs.toString())
.then(r => r.json())
.then(d => {
showSpinner(false);
if (d.error) { $('ubx_status').textContent = ''; alert(d.error); return; }
$('ubx_status').textContent = '';
$('ubx_summary_card').style.display = 'none';
$('ubx_trades_card').style.display = 'none';
ubxRenderParamSearch(d);
})
.catch(e => { showSpinner(false); $('ubx_status').textContent = ''; alert('오류: ' + e); });
}
function ubxRenderParamSearch(d) {
const card = $('ubx_ps_card'), tb = $('ubx_ps_tbody');
if (!card || !tb) return;
const top = d.top || [];
const meta = d.meta || {};
const dropped = meta.dropped_trend || [];
const dropTxt = dropped.length
? ` · 추세주 제외: ${dropped.map(x => `${x.code}(α${x.alpha}%)`).join(',')}`
: '';
$('ubx_ps_sub').innerHTML =
`종목 ${(d.codes || []).length} · 조합 ${meta.cartesian_product || 0} · 통과 ${meta.passed || 0} · ${meta.search_mode || ''} · 정렬:
${meta.rank_by || 'alpha'} `
+ ((meta.skipped_codes && meta.skipped_codes.length) ? ` · 데이터부족 제외: ${meta.skipped_codes.join(',')}` : '')
+ (dropTxt ? `
${dropTxt} ` : '');
if (!top.length) {
tb.innerHTML = '
통과한 조합 없음 (최소거래 낮추거나 기간 확대) ';
} else {
tb.innerHTML = top.map((r, i) => {
const cfgStr = Object.entries(r.apply_cfg || {}).map(([k, v]) => {
if (k === 'ratchet_tiers') return `ratchet=${(v != null && String(v).trim()) ? v : 'OFF'}`;
return `${k}=${v}`;
}).join(' ');
const cfgJson = encodeURIComponent(JSON.stringify(r.apply_cfg || {}));
const alpha = (r.alpha_pct != null) ? Number(r.alpha_pct) : null;
const bh = (r.buy_hold_pct != null) ? Number(r.buy_hold_pct) : null;
return `
${i + 1}
${alpha != null ? (alpha >= 0 ? '+' : '') + alpha + '%' : '-'}
${r.total_pnl}%
${bh != null ? bh + '%' : '-'}
${r.pf} ${r.win_rate}% ${r.n_trades}
${cfgStr}
ENV적용
`;
}).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) || '';
let _optunaPollStatus = '';
let _optunaJobsRefreshing = false;
/** 호가8방 표 재렌더 방지용 — 폴링마다 innerHTML 갈아끼우면 스크롤이 위로 튐 */
let _optunaPostFp = '';
/** 프로그레스 갱신 체감용 2초 · 표는 fingerprint로 안 다시 그림 */
const OPTUNA_POLL_MS_BUSY = 2000;
const OPTUNA_POLL_MS_IDLE = 2000;
async function optunaRefreshJobsIfNeeded(force) {
if (_optunaJobsRefreshing && !force) return;
_optunaJobsRefreshing = true;
try {
await optunaRefreshJobs();
} finally {
_optunaJobsRefreshing = false;
}
}
function optunaGoTab() {
btShowTab('optuna');
}
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 post = job.postprocess || {};
const pct = Number(prog.pct || 0);
const st = job.status || '';
const phase = job.phase || '';
const postBusy = phase === 'postprocess' || (st === 'running' && pct >= 99.9 && !post.ready && post.expect_ob !== false);
const finalizeBusy = phase === 'finalize';
const label = (job.label || job.strategy || 'Optuna') +
(prog.refine_phase === 'phase1' ? ' · 1차' : (prog.refine_phase === 'phase2' ? ' · 2차' : '')) +
(postBusy ? ' 후처리' : (finalizeBusy ? ' 저장중' : (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);
const postPct = Number(post.pct || 0);
if (postBusy) {
$('optuna_nav_pct').textContent = `후처리 ${postPct}%` + (post.stage ? ` · ${post.stage}` : '');
} else if (finalizeBusy) {
$('optuna_nav_pct').textContent = (post.hint || 'JSON 저장 중') + (met ? ` · ${met}` : '');
} else {
$('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' && !postBusy && !finalizeBusy);
fill.classList.toggle('is-error', st === 'error');
const postTrack = $('optuna_nav_post_track');
const postFill = $('optuna_nav_post_fill');
if (postTrack && postFill) {
// 사후호가방(expect_ob) 있을 때만 아래 후처리 트랙 표시
const showPost = (post.expect_ob !== false) && (postBusy || post.ready || postPct > 0);
postTrack.classList.toggle('d-none', !showPost);
postFill.style.width = `${Math.min(100, post.ready ? 100 : postPct)}%`;
postFill.classList.toggle('is-done', !!post.ready);
}
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 gatesEl = $('opt_stable_gates');
if (gatesEl) {
const g = sum && sum.stable_gates;
if (g) {
let t =
`안정점수 = 일평균PnL − λ×일표준편차 (원). 높을수록 좋음 · 만점 없음. ` +
`게이트: 손실일≤${g.max_losing_days} · 최악일≥${Number(g.min_worst_day_pnl).toLocaleString()} · ` +
`활성일≥${g.min_active_days} · λ=${g.stability_lambda}`;
if (g.fallback_rank_only || g.fallback_note) {
t += ` ·
${g.fallback_note || '게이트 0건 → 점수순 폴백(마이너스 포함)'} `;
} else if (g.n_gate_pass != null) {
t += ` · 게이트통과 ${g.n_gate_pass}건`;
}
gatesEl.innerHTML = t;
} else if (sum) {
gatesEl.textContent = '안정점수 = 일평균PnL − λ×일표준편차(원). 높을수록 좋음 · 만점 없음. (구 JSON은 게이트 미기록)';
} else {
gatesEl.textContent = '—';
}
}
optunaRenderModeCombo(sum);
optunaRenderModeTop10(sum);
optunaRenderTrailRec(sum);
optunaRenderPostprocess(sum);
optunaRenderOverfitBadges(sum);
optunaRenderOverfit(sum);
optunaRenderParamDistChart(sum);
}
const OPTUNA_CARD_TOP_N = 5;
function optunaRenderModeCombo(sum) {
const el = $('opt_mode_combo_body');
const m = sum && sum.mode_combo_summary;
const rows = (sum && sum.top5_mode) || [];
// 사후합격 Top10과 동일 표 — mode는 축최빈 1회 실측이라 보통 1행
optunaFillRankTbody(
'opt_top5_mode_tbody',
rows,
'mode',
(sum
? 'mode_combo 실측 없음 (구 JSON·미산출)'
: '완료 후 표시'),
);
if (!el) return;
if (!m || !m.ok) {
const note = (sum && sum.mode_combo_note) || '';
el.innerHTML = sum
? (`
mode_combo 실측 없음 `
+ (note ? `
${note}
` : ''))
: '완료 후 표시';
return;
}
const vs = m.vs_best || {};
const vsLine = (vs.best_pnl != null && vs.mode_pnl != null)
? `vs학습1위 PnL ${Number(vs.mode_pnl).toLocaleString()} / ${Number(vs.best_pnl).toLocaleString()}`
: '';
el.innerHTML =
`
` +
`Top${m.top_n != null ? m.top_n : 'N'} 축최빈 조립` +
(m.pool_kind ? ` · pool=${m.pool_kind}` : '') +
(m.pool_size != null ? ` · ${m.pool_size}건` : '') +
(vsLine ? ` · ${vsLine}` : '') +
` · 아래 표=사후합격과 같은 열 (안정·과적합 포함). trial 번호는 조립이라 없음.` +
`
` +
(m.note ? `
${m.note}
` : '') +
`
` +
`mode 타점 DB적용 ` +
`
`;
}
function optunaRenderModeTop10(sum) {
const metaEl = $('opt_mode_top_meta');
const meta = (sum && sum.mode_consensus_meta) || {};
if (metaEl) {
if (!sum) {
metaEl.textContent = '—';
} else if (meta.mode_pool_size != null || meta.note) {
metaEl.innerHTML =
`pool ${meta.mode_pool_kind || 'positive'}`
+ (meta.mode_pool_size != null ? ` · ${meta.mode_pool_size}건` : '')
+ (meta.mode_params_keys != null ? ` · 축 ${meta.mode_params_keys}개` : '')
+ (meta.note ? ` ·
${meta.note} ` : '')
+ (meta.scoring ? ` ·
${meta.scoring} ` : '');
} else {
metaEl.textContent = 'mode Top10 — 완료 후 표시';
}
}
optunaFillModeTopTbody(
'opt_top5_mode_top_tbody',
(sum && sum.top5_consensus) || [],
'consensus',
sum ? 'mode Top10 없음 (pool 0 · grid/results 확인)' : '완료 후 표시',
);
optunaRenderModeBandTop3(sum, window._optunaLastJob || null);
}
function optunaDailyAvgPnlCell(r) {
const v = (r.period_daily_avg_pnl != null) ? r.period_daily_avg_pnl : r.daily_pnl_mean;
if (v == null) return '
— ';
const n = Number(v);
const cls = n >= 0 ? 'text-pnl-pos' : 'text-pnl-neg';
return `
${optunaFmtNum(v)} `;
}
function optunaDailyAvgPctCell(r) {
let p = r.period_daily_avg_pct;
if (p == null && r.daily_avg_pct != null) p = r.daily_avg_pct;
if (p == null && r.bot_pct != null && r.n_period_trading_days > 0) {
p = Number(r.bot_pct) / Number(r.n_period_trading_days);
}
if (p == null) return '
— ';
const n = Number(p);
const cls = n >= 0 ? 'text-pnl-pos' : 'text-pnl-neg';
const sign = n > 0 ? '+' : '';
return `
${sign}${optunaFmtNum(n, 3)}% `;
}
function optunaModeTopTableRow(r, source) {
const rank = r.rank || 1;
const src = source;
const applyCls = 'btn-outline-success';
const cm = r.consensus_match_pct;
const cmN = r.consensus_match_n;
const cmOf = r.consensus_match_of;
const cmCell = (cm != null && cmOf != null)
? `${optunaFmtNum(cm, 1)}%
${cmN}/${cmOf}축 밴드
`
: '—';
return `
${rank}
#${r.optuna_trial_number ?? '—'}
${cmCell}
${optunaFmtNum(r.total_trades, 0)}
${optunaFmtNum(r.win_rate, 1)}%
${optunaFmtNum(r.pf, 2)}
${optunaFmtNum(r.total_pnl)}
${optunaDailyAvgPnlCell(r)}
${optunaDailyAvgPctCell(r)}
${optunaObWhipCell(r)}
${r.n_losing_days != null ? optunaFmtNum(r.n_losing_days, 0) : '—'}
${r.worst_day_pnl != null ? optunaFmtNum(r.worst_day_pnl) : '—'}
${optunaStableScoreCell(r)}
${optunaOverfitCell(r)}
보기
적용
`;
}
function optunaFillModeTopTbody(tbodyId, rows, source, emptyMsg) {
const tb = $(tbodyId);
if (!tb) return;
if (!rows || !rows.length) {
tb.innerHTML = `
${emptyMsg} `;
return;
}
tb.innerHTML = rows.map((r) => optunaModeTopTableRow(r, source)).join('');
}
function optunaRenderOverfit(sum) {
const scoreEl = $('opt_overfit_score');
const facTb = $('opt_overfit_factors_tbody');
const distTb = $('opt_thresh_dist_tbody');
const poolEl = $('opt_thresh_pool');
const noteEl = $('opt_overfit_note');
const d = sum && sum.overfit_diagnostics;
if (!d || d.overfit_risk_pct == null) {
const done = window._optunaLastJob && window._optunaLastJob.status === 'done';
const empty = done ? '완료 · 진단 없음(후보 0건)' : '완료 후 표시';
if (scoreEl) scoreEl.textContent = empty;
if (facTb) facTb.innerHTML = `
${empty} `;
if (distTb) distTb.innerHTML = `
${empty} `;
if (poolEl) poolEl.textContent = '';
if (noteEl) noteEl.textContent = '—';
return;
}
const risk = Number(d.overfit_risk_pct);
const ready = Number(d.apply_readiness_pct);
const verd = d.verdict || '';
let cls = 'text-muted';
if (verd === '비권장' || risk >= 70) cls = 'text-pnl-neg';
else if (verd === '주의' || risk >= 40) cls = 'text-warning';
else cls = 'text-pnl-pos';
if (scoreEl) {
scoreEl.innerHTML =
`
과적합 ${risk}% · 적용가능 ${ready}% · ${verd} ` +
(d.verdict_ko ? `
${d.verdict_ko}
` : '');
}
const factors = d.factors || [];
if (facTb) {
if (!factors.length) {
facTb.innerHTML = '
요인 없음 ';
} else {
facTb.innerHTML = factors.map(f => `
${f.label || f.id || '—'}
+${optunaFmtNum(f.points, 1)}
${f.detail || ''}
`).join('');
}
}
if (poolEl) {
poolEl.textContent = d.threshold_pool
? `(${d.threshold_pool} · n=${d.threshold_pool_n || 0})`
: '';
}
const dist = d.threshold_distribution || [];
if (distTb) {
if (!dist.length) {
distTb.innerHTML = '
분포 없음 ';
} else {
distTb.innerHTML = dist.map(r => {
const share = r.mode_share != null ? `${(Number(r.mode_share) * 100).toFixed(0)}%` : '—';
const fmt = (v) => (v == null || v === '') ? '—' : optunaFmtNum(v, 4);
return `
${r.param || ''}
${fmt(r.median)}
${fmt(r.p25)}
${fmt(r.p75)}
${r.mode != null ? r.mode : '—'}
${share}
`;
}).join('');
}
}
if (noteEl) noteEl.textContent = d.note || '—';
}
function optunaRefinePhaseLabel(job) {
const sy = String((job && job.study_name) || '');
const sh = String((job && job.study_short) || '');
if (sy.includes('refine2') || sh === 'refine2') return '2차 TPE';
if (sy.includes('refine1') || sh === 'refine1') return '1차 TPE';
const ph = (job && job.progress && job.progress.refine_phase) || '';
if (ph === 'phase2') return '2차 TPE';
if (ph === 'phase1') return '1차 TPE';
return '';
}
function optunaFormatGatedTopRowHtml(t, i, showBorder) {
const dAvg = (t.period_daily_avg_pnl != null) ? t.period_daily_avg_pnl : t.daily_pnl_mean;
const dPct = (t.period_daily_avg_pct != null) ? t.period_daily_avg_pct : t.daily_avg_pct;
const rank = t.rank || i;
const bdr = showBorder !== false ? ' border-bottom border-secondary' : '';
return `
#${i} trial #${t.optuna_trial_number ?? '—'}
PnL ${Number(t.total_pnl || 0).toLocaleString()} 원
${dAvg != null ? `일평균 ${Number(dAvg).toLocaleString()}원 ` : ''}
${dPct != null ? `일평균 ${Number(dPct) >= 0 ? '+' : ''}${Number(dPct).toFixed(3)}% ` : ''}
WR ${Number(t.win_rate || 0).toFixed(1)}% · PF ${Number(t.pf || 0).toFixed(2)} · ${Number(t.total_trades || 0)}건
${optunaObWhipCell(t)}
보기
백테
적용
`;
}
function optunaBuildGatedTop3Html(rows, opts) {
const o = opts || {};
const n = o.topN || OPTUNA_CARD_TOP_N;
const list = (rows || []).slice(0, n);
if (!list.length) return '';
const pr = o.periodRange ? ` · 기간
${o.periodRange} ` : '';
const phase = o.phaseLabel || '사후합격';
const note = o.running
? '
(진행 중 · 사후합격 미확정) '
: '';
let html = `
${phase} Top${n}${pr}${note}
`;
list.forEach((t, i) => {
html += optunaFormatGatedTopRowHtml(t, i + 1, i < list.length - 1);
});
return html;
}
function optunaRenderGatedTop3Card(sum, job) {
const body = $('opt_gated_top3_body');
const title = $('opt_gated_top3_title');
if (!body) return;
const phase = optunaRefinePhaseLabel(job);
const phaseTxt = phase ? `${phase} 사후합격 Top${OPTUNA_CARD_TOP_N}` : `사후합격 Top${OPTUNA_CARD_TOP_N}`;
if (title) {
title.innerHTML = `${phaseTxt}
DB 적용 후보 · 일평균(원/%) · 익절/손절 비교 `;
}
const pr = (job && job.start && job.end)
? optunaFmtPeriodShort(job.start, job.end)
: '';
if (job && job.status === 'running' && job.live_summary) {
const ls = job.live_summary;
const ph = ls.refine_phase === 'phase2' ? '2차 TPE' : (ls.refine_phase === 'phase1' ? '1차 TPE' : phase);
const rows = (ls.top3_gated && ls.top3_gated.length)
? ls.top3_gated
: (ls.top3_learn || []);
if (rows.length) {
body.innerHTML = optunaBuildGatedTop3Html(rows, {
phaseLabel: ph ? `${ph} learn` : 'learn',
periodRange: ls.period_range || pr,
running: !ls.top3_gated || !ls.top3_gated.length,
});
return;
}
}
const gated = (sum && sum.top5_gated) || [];
if (!gated.length) {
const done = job && job.status === 'done';
body.innerHTML = done
? '
사후합격 후보 없음 '
: '
완료 후 표시 ';
return;
}
body.innerHTML = optunaBuildGatedTop3Html(gated, {
phaseLabel: phase ? `${phase} 사후합격` : '사후합격',
periodRange: pr,
running: false,
});
}
function optunaRenderModeBandTop3(sum, job) {
const el = $('opt_mode_band_top3');
if (!el) return;
const rows = (sum && sum.top5_consensus) || [];
if (!rows.length) {
el.innerHTML = `
mode 밴드 근접 Top${OPTUNA_CARD_TOP_N} 없음 `;
return;
}
const pr = (job && job.start && job.end)
? optunaFmtPeriodShort(job.start, job.end)
: '';
const pool = (sum.mode_consensus_meta && sum.mode_consensus_meta.mode_pool_size) || null;
el.innerHTML =
optunaBuildModeTop3Html(rows.slice(0, OPTUNA_CARD_TOP_N), {
periodRange: pr,
poolSize: pool,
topN: OPTUNA_CARD_TOP_N,
title: `mode 밴드 근접 Top${OPTUNA_CARD_TOP_N} (분포 참고 · DB 1순위 아님)`,
});
}
function optunaRenderParamDistChart(sum) {
const el = $('opt_param_dist_chart');
if (!el) return;
const d = sum && sum.overfit_diagnostics;
const dist = (d && d.threshold_distribution) || [];
if (!dist.length) {
const done = window._optunaLastJob && window._optunaLastJob.status === 'done';
el.innerHTML = done
? '
분포 데이터 없음 '
: '
완료 후 표시 ';
return;
}
const rows = dist.filter((r) => {
if (r.p25 == null || r.p75 == null) return false;
const p = String(r.param || '');
return p && !p.endsWith('_mode') && p !== 'sl_mode';
});
const pri = ['tp_pct', 'sl_pct', 'trail_pct', 'trail_arm_pct', 'max_daily_chg', 'vol_mult'];
rows.sort((a, b) => {
const ia = pri.indexOf(String(a.param || ''));
const ib = pri.indexOf(String(b.param || ''));
const pa = ia >= 0 ? ia : 999;
const pb = ib >= 0 ? ib : 999;
if (pa !== pb) return pa - pb;
return String(a.param || '').localeCompare(String(b.param || ''));
});
if (!rows.length) {
el.innerHTML = '
수치형 분포 없음 ';
return;
}
const poolNote = d.threshold_pool
? `
표본: ${d.threshold_pool} · n=${d.threshold_pool_n || 0}
`
: '';
const bars = rows.map((r) => {
const p25 = Number(r.p25);
const p75 = Number(r.p75);
const med = r.median != null ? Number(r.median) : null;
const mode = r.mode != null ? Number(r.mode) : null;
const vals = [p25, p75, med, mode].filter((v) => v != null && !Number.isNaN(v));
const lo = Math.min(...vals) * 0.92;
const hi = Math.max(...vals) * 1.08;
const span = hi - lo || 1;
const pct = (v) => Math.max(0, Math.min(100, ((v - lo) / span) * 100));
const share = r.mode_share != null ? `${(Number(r.mode_share) * 100).toFixed(0)}%` : '—';
const fmt = (v) => (v == null || Number.isNaN(v)) ? '—' : optunaFmtNum(v, 3);
let marks = '';
if (med != null && !Number.isNaN(med)) {
marks += `
`;
}
if (mode != null && !Number.isNaN(mode)) {
marks += `
`;
}
const l = pct(p25).toFixed(1);
const w = Math.max(1, pct(p75) - pct(p25)).toFixed(1);
return `
${r.param}
mode ${fmt(r.mode)} (${share})
p25 ${fmt(p25)} · med ${fmt(med)} · p75 ${fmt(p75)}
`;
}).join('');
el.innerHTML = poolNote + bars;
}
function optunaRenderTrailRec(sum) {
const body = $('opt_daily_trail_rec_body');
if (!body) return;
const rec = sum && sum.daily_trail_recommend;
if (!rec) {
body.textContent = '완료 후 표시';
return;
}
if (!rec.ok) {
body.innerHTML =
`
추천 없음 — ${rec.reason || '양수 PnL 앵커 없음'}`;
return;
}
const arm = Number(rec.arm_krw || 0).toLocaleString();
const anchor = Number(rec.anchor_krw || 0).toLocaleString();
body.innerHTML =
`
${rec.prefix || ''} · ENABLED=true · MODE=${rec.mode || 'trailing'}` +
`
ARM
${arm} 원 · TIERS
${rec.tiers || ''}` +
`
앵커 ${anchor}원 (mode/median/best×frac) · 타점 적용과 별도 · 이후 운영탭에서 끄기/수정 가능 `
+ `
트레일 합의 적용 `;
}
function optunaOverfitCell(r) {
const p = r && r.overfit_risk_pct;
if (p == null || Number.isNaN(Number(p))) return '
— ';
const n = Number(p);
let cls = 'text-pnl-pos';
if (n >= 70) cls = 'text-pnl-neg';
else if (n >= 40) cls = 'text-warning';
const v = r.overfit_verdict_ui || r.overfit_verdict || '';
return `
${n}%${v ? ' · ' + v : ''} `;
}
function optunaStableScoreCell(r) {
if (!r || r.stability_score == null) return '
— ';
const n = Number(r.stability_score);
const cls = n >= 0 ? 'text-pnl-pos' : 'text-pnl-neg';
return `
${optunaFmtNum(r.stability_score, 2)} `;
}
function optunaPostBusy(job) {
if (!job) return false;
const rerun = job.postprocess_rerun || {};
if (String(rerun.status || '') === 'running') return true;
if (job.status === 'done' || job.status === 'error') return false;
if (job.phase === 'postprocess') return true;
const prog = job.progress || {};
return job.status === 'running' && Number(prog.pct || 0) >= 99.9;
}
function optunaObWhipCell(r) {
if (!r) return '
— ';
const on = r.ob_on;
const whip = r.whip_on;
const hasExit = r.tp_pct != null || r.tp_max_pct != null || r.sl_pct != null;
if (on == null && whip == null && !hasExit && !r.ob_summary) {
return '
— ';
}
const spr = r.ob_spread != null ? Number(r.ob_spread).toFixed(1) : null;
const ratio = r.ob_ratio != null ? Number(r.ob_ratio).toFixed(2) : null;
const ask = r.ob_ask != null ? Number(r.ob_ask).toFixed(0) : null;
const tip = (Array.isArray(r.ob_summary_lines) && r.ob_summary_lines.length)
? r.ob_summary_lines.join(' | ')
: (r.ob_summary || '본 TPE 호가·익절·손절·휩쏘');
const lines = [];
if (on === true) {
const bits = ['호가ON'];
if (spr != null) bits.push(`spr${spr}`);
if (ratio != null) bits.push(`r${ratio}`);
if (ask != null) bits.push(`ask×${ask}`);
lines.push(`
${bits.join(' ')}
`);
} else if (on === false) {
lines.push('
호가OFF
');
}
if (hasExit) {
const eb = [];
if (r.tp_pct != null) eb.push(`익절${Number(r.tp_pct).toFixed(1)}%`);
if (r.tp_max_pct != null) eb.push(`상한${Number(r.tp_max_pct).toFixed(1)}%`);
if (r.sl_pct != null) eb.push(`손절${Number(r.sl_pct).toFixed(1)}%`);
lines.push(`
${eb.join(' ')}
`);
}
if (whip === true) {
const wb = ['휩쏘ON'];
if (r.whip_subbar_sec != null) wb.push(`sub${Number(r.whip_subbar_sec)}s`);
if (r.whip_lookback_sec != null) wb.push(`lb${Number(r.whip_lookback_sec)}s`);
if (r.whip_dip_pct != null) {
let d = Number(r.whip_dip_pct);
if (d < 0.5) d *= 100;
wb.push(`dip${d.toFixed(2)}%`);
}
if (r.whip_recovery_tol_pct != null) {
let t = Number(r.whip_recovery_tol_pct);
if (t < 0.5) t *= 100;
wb.push(`tol${t.toFixed(2)}%`);
}
lines.push(`
${wb.join(' ')}
`);
} else if (whip === false && (on != null || hasExit)) {
lines.push('
휩쏘OFF
');
}
if (!lines.length) {
return `
${r.ob_summary || '—'} `;
}
return `
${lines.join('')}
`;
}
function optunaRankTableRow(r, source) {
const rank = r.rank || 1;
const src = source;
const detailBtn = '';
const applyCls = src === 'stable' ? 'btn-outline-info' : 'btn-outline-success';
return `
${rank}
#${r.optuna_trial_number ?? '—'}
${optunaFmtNum(r.total_trades, 0)}
${optunaFmtNum(r.win_rate, 1)}%
${optunaFmtNum(r.pf, 2)}
${optunaFmtNum(r.total_pnl)}
${optunaDailyAvgPnlCell(r)}
${optunaDailyAvgPctCell(r)}
${optunaObWhipCell(r)}
${r.n_losing_days != null ? optunaFmtNum(r.n_losing_days, 0) : '—'}
${r.worst_day_pnl != null ? optunaFmtNum(r.worst_day_pnl) : '—'}
${optunaStableScoreCell(r)}
${optunaOverfitCell(r)}
보기
${src !== 'mode' ? `백테 ` : ''}
${detailBtn}
적용
`;
}
function optunaFillRankTbody(tbodyId, rows, source, emptyMsg) {
const tb = $(tbodyId);
if (!tb) return;
if (!rows || !rows.length) {
tb.innerHTML = `
${emptyMsg} `;
return;
}
tb.innerHTML = rows.map((r) => optunaRankTableRow(r, source)).join('');
}
/** Optuna 후처리 표 — 행=gated# / mode / live, 열=호가·휩쏘·트레일 (원 정수). */
function optunaOverfitBadgeHtml(pct, verdict) {
const p = Number(pct);
if (pct == null || Number.isNaN(p)) return '
— ';
let cls = 'text-pnl-pos';
let tone = '상대적으로 낮음';
if (p >= 70) { cls = 'text-pnl-neg'; tone = '위험 · 비권장'; }
else if (p >= 40) { cls = 'text-warning'; tone = '주의'; }
const v = verdict || tone;
return `
과적합 가능도 ${p}% · ${v} `;
}
function optunaRenderOverfitBadges(sum) {
const pick = (key) => ((sum && sum[key]) || [])[0] || null;
const set = (id, row) => {
const el = $(id);
if (!el) return;
el.innerHTML = optunaOverfitBadgeHtml(
row && row.overfit_risk_pct,
row && (row.overfit_verdict_ui || row.overfit_verdict),
);
};
set('opt_overfit_badge_gated', pick('top5_gated'));
set('opt_overfit_badge_stable', pick('top5_stable'));
const modeRow = ((sum && sum.compare_rows) || []).find((r) => r.source === 'mode');
set('opt_overfit_badge_mode', modeRow);
}
/** 호가8방 UI: 승률 색 기준(사후게이트 WR와 맞춤). PnL은 부호 색. */
const OPTUNA_OB8_WR_GOOD_PCT = 40;
function _optunaOb8WrSpan(wr) {
if (!Number.isFinite(wr)) return '';
const cls = wr >= OPTUNA_OB8_WR_GOOD_PCT ? 'text-pnl-pos' : 'text-pnl-neg';
return `
승률 ${wr.toFixed(1)}% `;
}
function _optunaOb8PnlSpan(pnl) {
if (pnl == null || !Number.isFinite(Number(pnl))) return '';
const n = Math.trunc(Number(pnl));
const cls = n > 0 ? 'text-pnl-pos' : (n < 0 ? 'text-pnl-neg' : '');
const sign = n > 0 ? '+' : '';
return `
${sign}${n.toLocaleString()}원 `;
}
function _optunaAxisStatsHtml(ax, recHint) {
if (!ax || !ax.ok) {
const r = (ax && ax.reason) || (recHint && recHint.reason) || '없음';
const ko = {
light_skip: '후처리 미저장 · 진행 중이면 완료 후 새로고침',
no_replay_fills: '체결 재실행 없음',
not_enough_trades: '체결 부족',
'n/a_strategy': '해당없음',
none: '없음',
};
return `
${ko[r] || r} `;
}
const p = ax.params || {};
const rs = ax.recommended_stats || {};
const os = ax.orig_stats || {};
const pnl = rs.pnl != null ? Math.trunc(Number(rs.pnl)) : null;
const cnt = rs.count;
const wr = rs.win_rate != null ? Number(rs.win_rate) : NaN;
const origWr = os.win_rate != null ? Number(os.win_rate) : NaN;
const origPnl = os.pnl != null ? Math.trunc(Number(os.pnl)) : null;
const recBitsP = [];
if (p.orderbook_max_spread_pct != null) {
recBitsP.push(`스프레드≤${p.orderbook_max_spread_pct}% · 잔량≥${p.orderbook_min_bid_ask_ratio}`);
}
if (p.exit_ob_enabled != null) {
recBitsP.push((p.exit_ob_enabled ? 'EXIT ON' : 'EXIT OFF')
+ (p.exit_ob_ratio_min != null ? ` · OR≥${p.exit_ob_ratio_min}` : '')
+ (p.exit_ob_min_hold_bars != null ? ` · 보유${p.exit_ob_min_hold_bars}` : ''));
}
if (p.stop_ob_enabled != null) {
recBitsP.push((p.stop_ob_enabled ? 'STOP ON' : 'STOP OFF')
+ (p.stop_ob_min_loss_pct != null ? ` · 손실${p.stop_ob_min_loss_pct}` : '')
+ (p.stop_ob_ratio_min != null ? ` · OR≥${p.stop_ob_ratio_min}` : ''));
}
if (p.whipsaw_dip_pct != null) {
recBitsP.push(`sub=${p.whipsaw_subbar_sec} · lb=${p.whipsaw_lookback_sec} · dip=${p.whipsaw_dip_pct}`);
}
const recLine = recBitsP.join(' · ');
const wrDrop = (Number.isFinite(wr) && Number.isFinite(origWr) && wr + 0.05 < origWr);
const recBits = [];
if (cnt != null) recBits.push(cnt + '건');
if (Number.isFinite(wr)) recBits.push(_optunaOb8WrSpan(wr));
if (pnl != null) recBits.push(_optunaOb8PnlSpan(pnl));
const origBits = [];
if (os.count != null && os.count !== cnt) origBits.push(os.count + '건');
if (Number.isFinite(origWr)) origBits.push(_optunaOb8WrSpan(origWr));
if (origPnl != null && origPnl !== pnl) origBits.push(_optunaOb8PnlSpan(origPnl));
const dropHint = wrDrop ? '
(사후호가 승률↓) ' : '';
return `
${recLine || '추천있음'}`
+ (recBits.length ? `
사후호가(참고) ${recBits.join(' · ')}${dropHint}
` : '')
+ (origBits.length
? `
필터전(호가스냅) ${origBits.join(' · ')}
`
: '')
+ `
`;
}
function optunaOb8wayState(ob) {
const combos = ob && ob.combos;
const hasCombos = !!(combos && typeof combos === 'object' && !Array.isArray(combos) && Object.keys(combos).length);
const reason = String((ob && ob.reason) || '').trim();
const tc = (ob && ob.trade_count != null) ? Number(ob.trade_count) : null;
const fc = (ob && ob.fill_count != null) ? Number(ob.fill_count) : null;
if (hasCombos) return { kind: 'ok', reason, tc, fc, combos };
if (reason && reason !== 'light_skip' && reason !== 'none') {
return { kind: 'failed', reason, tc, fc, combos: combos || {} };
}
return { kind: 'legacy', reason, tc, fc, combos: combos || {} };
}
function optunaObReasonKo(st) {
if (!st || st.kind === 'ok') return '';
if (st.reason === 'not_enough_trades') {
const n = Number.isFinite(st.tc) ? st.tc : '?';
const fill = (Number.isFinite(st.fc) && st.fc > n) ? (` · 체결${st.fc}`) : '';
return `호가스냅 ${n}건(<3)${fill} · 8방 TPE 생략`;
}
if (st.reason === 'no_orderbook_snapshots') return '호가 스냅샷 테이블 공백';
if (st.reason === 'no_replay_fills') return '체결 재실행 없음';
if (st.kind === 'legacy') return '구JSON(8방 키 없음)';
return st.reason || '8방 미산출';
}
function optunaPool8wayKind(anchors) {
const pool = (anchors || []).filter((a) => a && (a.role === 'gated' || a.role === 'learn' || a.role === 'mode'));
if (!pool.length) return 'legacy';
let anyOk = false;
let anyFailed = false;
pool.forEach((a) => {
const st = optunaOb8wayState(a.orderbook);
if (st.kind === 'ok') anyOk = true;
else if (st.kind === 'failed') anyFailed = true;
});
if (anyOk) return 'ok';
if (anyFailed) return 'failed';
return 'legacy';
}
function optunaSyncRerunButton(sum, job) {
const btn = $('opt_btn_pp_rerun');
const stEl = $('opt_pp_rerun_status');
const topn = sum && sum.postprocess_topn;
const anchors = (topn && topn.postprocess_by_anchor) || [];
const kind = optunaPool8wayKind(anchors);
const ran = !!(topn && topn.run_ob_whipsaw);
const busy = optunaPostBusy(job);
const needWarn = kind !== 'ok';
if (btn) {
btn.disabled = !!busy;
btn.classList.toggle('btn-warning', needWarn && !busy);
btn.classList.toggle('btn-outline-secondary', !(needWarn && !busy));
}
if (!stEl) return;
const pp = job && job.postprocess_rerun;
const prog = job && job.postprocess;
if (pp && pp.status === 'running') {
stEl.textContent = (prog && prog.hint) || ('재실행 중… 로그 ' + (pp.log_path || ''));
return;
}
if (pp && pp.status === 'error') {
stEl.textContent = '재실행 실패 · ' + (pp.error || '');
return;
}
if (kind === 'legacy' && (!ran || !anchors.length)) {
stEl.textContent = '구JSON · 「이 잡 후처리 재실행」으로 8방을 채우세요';
return;
}
if (kind === 'failed' || (kind === 'legacy' && ran)) {
const st0 = optunaOb8wayState(((anchors.find((a) => a.role === 'gated') || {}).orderbook));
stEl.textContent = (optunaObReasonKo(st0) || '8방 미산출')
+ ' · 재실행해도 호가스냅이 안 늘면 동일';
return;
}
if (pp && pp.status === 'done') {
stEl.textContent = '재실행 완료 · ' + (pp.log_path || '');
return;
}
if (prog && prog.hint) stEl.textContent = prog.hint;
}
/** Optuna 호가 8방 표시명 — 「진입」=매수호가축 (TRIGGER 진입방어와 구분) */
const OPTUNA_OB_COMBO_LABELS = {
base: '000 타점만(호가OFF)',
e: '100 매수호가만',
x: '010 익절호가만',
s: '001 손절호가만',
ex: '110 매수+익절호가',
es: '101 매수+손절호가',
xs: '011 익절+손절호가',
exs: '111 호가전부',
'000': '000 타점만(호가OFF)',
'100': '100 매수호가만',
'010': '010 익절호가만',
'001': '001 손절호가만',
'110': '110 매수+익절호가',
'101': '101 매수+손절호가',
'011': '011 익절+손절호가',
'111': '111 호가전부',
entry: '100 매수호가만',
exit: '110 매수+익절호가',
stop: '111 호가전부',
};
function optunaObComboLabel(cid, fallback) {
const k = String(cid || '').trim();
if (OPTUNA_OB_COMBO_LABELS[k]) return OPTUNA_OB_COMBO_LABELS[k];
// 구 JSON 라벨 → 신규
const old = String(fallback || '');
const remap = {
'100 진입만': '100 매수호가만',
'100 진입': '100 매수호가',
'010 익절만': '010 익절호가만',
'001 손절만': '001 손절호가만',
'110 진입+익절': '110 매수+익절호가',
'101 진입+손절': '101 매수+손절호가',
'011 익절+손절': '011 익절+손절호가',
'111 전부': '111 호가전부',
'111 호가전부': '111 호가전부',
};
return remap[old] || old || k || '—';
}
function _optunaObComboTable(ob, canExitStop, rowSrc, rowRk, isLive, row, whipSkip) {
const st = optunaOb8wayState(ob);
const combos = st.combos || {};
// 8방 정의는 항상 8행. 전략이 익절/손절 호가축을 안 쓰면 행은 보이되 「미사용」.
const allRows = [
['base', OPTUNA_OB_COMBO_LABELS.base, true],
['e', OPTUNA_OB_COMBO_LABELS.e, true],
['x', OPTUNA_OB_COMBO_LABELS.x, !!canExitStop],
['s', OPTUNA_OB_COMBO_LABELS.s, !!canExitStop],
['ex', OPTUNA_OB_COMBO_LABELS.ex, !!canExitStop],
['es', OPTUNA_OB_COMBO_LABELS.es, !!canExitStop],
['xs', OPTUNA_OB_COMBO_LABELS.xs, !!canExitStop],
['exs', OPTUNA_OB_COMBO_LABELS.exs, !!canExitStop],
];
const hasCombos = st.kind === 'ok';
const btnOb = (cid, used) => {
if (isLive || !rowSrc) return '
참고 ';
if (!used) return '
이 전략 미사용 ';
const c = hasCombos ? (combos[cid] || {}) : null;
const disabled = (cid !== 'base' && hasCombos && !(c && c.ok));
if (disabled) return `
호가만 `;
if (cid !== 'base' && !hasCombos) {
return `
호가만 `;
}
return `
호가만 `;
};
const btnWhip = (cid, used) => {
if (isLive || !rowSrc) return '
— ';
if (whipSkip) return '
휩쏘 해당없음 ';
if (!used) return '
— ';
const c = hasCombos ? (combos[cid] || {}) : null;
const ws = (c && c.whipsaw) || {};
// +휩쏘 = DB 적용만. 탐색은 「이 잡 후처리 재실행」.
if (cid !== 'base' && hasCombos && !(c && c.ok)) {
return `
휩쏘적용 `;
}
if (ws && ws.ok === false && ws.reason === 'n/a_strategy') {
return '
해당없음 ';
}
if (!ws || ws.ok !== true) {
const why = (ws && ws.reason) ? String(ws.reason) : '미산출';
return `
휩쏘적용 `;
}
return `
휩쏘적용 `;
};
const whipCell = (cid, used) => {
if (!used) return '
— ';
if (whipSkip) return '
해당없음 ';
const c = hasCombos ? (combos[cid] || {}) : null;
const ws = (c && c.whipsaw) || null;
if (!ws) return '
미산출 ';
if (ws.ok) return _optunaAxisStatsHtml(ws, ws);
const why = ws.reason || '없음';
if (why === 'n/a_strategy') return '
이 전략 미사용 ';
if (why === 'not_enough_trades') {
const n = ws.trade_count != null ? ws.trade_count : '?';
return `
통과 ${n}건(<min) · 스킵 `;
}
return `
${why} `;
};
const trs = allRows.map(([id, lab, used]) => {
if (!used) {
return `
${lab} — `
+ `이 전략에는 이 호가축 없음 `
+ `— 미사용 `;
}
const c = hasCombos ? (combos[id] || {}) : null;
let nt;
let stats;
if (hasCombos) {
nt = (c && c.n_trials != null) ? c.n_trials : '—';
stats = _optunaAxisStatsHtml(c || { ok: false, reason: '없음' }, ob);
} else if (id === 'base' && row) {
nt = st.kind === 'legacy' ? '구JSON' : '—';
const bits = [];
if (row.total_trades != null) bits.push(row.total_trades + '건');
if (row.win_rate != null) bits.push(_optunaOb8WrSpan(Number(row.win_rate)));
if (row.total_pnl != null) bits.push(_optunaOb8PnlSpan(row.total_pnl));
const why = optunaObReasonKo(st);
stats = (bits.length ? `
${bits.join(' · ')}
` : '')
+ (why ? `
${why}
` : '
8방 미산출 ');
} else {
nt = st.kind === 'legacy' ? '구JSON' : '—';
stats = `
${optunaObReasonKo(st) || (st.kind === 'legacy' ? '후처리 재실행 필요' : '8방 미산출')} `;
}
return `
${lab} ${nt} ${stats} `
+ `${whipCell(id, used)} `
+ `${btnOb(id, used)} ${btnWhip(id, used)} `;
}).join('');
const foot = !canExitStop
? '스캘핑·꼬리: 호가
000·100 만. 「호가만」=호가 ON +
휩쏘 OFF·수치비움 (낙폭·방어 등 타점은 안 바꿈). 「휩쏘적용」만 휩쏘를 켭니다. 방별 휩쏘 TPE는
후처리 재실행 .'
: (st.kind === 'ok'
? '8방 호가 TPE → 방별 휩쏘는
후처리 재실행 . 「호가만」=휩쏘 OFF·비움(타점 유지) / 「휩쏘적용」=DB 적용만.'
: (st.kind === 'legacy'
? '구JSON · 「이 잡 후처리 재실행」으로 8방·방별휩쏘를 채우세요.'
: `${optunaObReasonKo(st)} · 재실행해도 호가스냅이 안 늘면 동일.`));
// 필터전 ≠ mode/타점 전체: 호가스냅 연동 분만 후처리 표본
const os0 = (ob && ob.orig_stats) || {};
const snapN = (os0.count != null) ? Number(os0.count)
: (st.tc != null ? Number(st.tc) : null);
const fillN = (st.fc != null) ? Number(st.fc) : null;
const anchorN = (row && row.total_trades != null) ? Number(row.total_trades) : null;
const anchorWr = (row && row.win_rate != null) ? Number(row.win_rate) : NaN;
const anchorPnl = (row && row.total_pnl != null) ? Math.trunc(Number(row.total_pnl)) : null;
let baseHint = '';
if (anchorN != null || snapN != null) {
const bits = [];
if (anchorN != null) {
let a = `타점전체 ${anchorN}건`;
if (Number.isFinite(anchorWr)) a += ` · 승률 ${anchorWr.toFixed(1)}%`;
if (anchorPnl != null) a += ` · PnL ${anchorPnl.toLocaleString()}원`;
bits.push(a);
}
if (snapN != null) {
bits.push(`필터전(호가스냅) ${snapN}건`
+ (fillN != null && fillN > snapN ? ` · 체결원본 ${fillN}` : ''));
}
baseHint = `
`
+ `필터전 ≠ 위 mode/타점 숫자 . ${bits.join(' → ')}. `
+ `방마다 바뀌는 건 사후호가(참고) 만. TPE에 호가가 포함되면 본 trial PnL이 정본. mode WR/PnL은 위 표를 보세요.`
+ `
`;
}
// 8방이 이미 있어도 「후처리 재실행」으로 방별 휩쏘를 다시 돌릴 수 있게
const rerunBtn = (!isLive)
? '
이 잡 후처리 재실행(호가·방별휩쏘)
'
: '';
return `${baseHint}
호가 방 호가TPE 호가 성적 이 방 휩쏘 적용
${trs}
${foot}
${rerunBtn}
`;
}
function _optunaBaseCell(a) {
const pnl = a.total_pnl != null ? Math.trunc(Number(a.total_pnl)) : null;
const wr = a.win_rate != null ? Number(a.win_rate) : NaN;
const bits = [];
if (a.total_trades != null) bits.push(a.total_trades + '건');
if (Number.isFinite(wr)) bits.push(_optunaOb8WrSpan(wr));
if (pnl != null) bits.push(_optunaOb8PnlSpan(pnl));
return `trial ${a.optuna_trial_number != null ? '#' + a.optuna_trial_number : '—'}`
+ (bits.length ? `
${bits.join(' · ')}
` : '');
}
function optunaSelectPostprocess(_source, _rank) {
// 호가 8방 UI 제거 — no-op
}
function optunaPostprocessFingerprint(sum) {
const topn = (sum && sum.postprocess_topn) || {};
const anchors = topn.postprocess_by_anchor || [];
const cons = topn.postprocess_consensus || {};
const cc = cons.combo || {};
const sel = window._optunaPostSel || {};
return [
sel.source || '',
sel.rank || '',
anchors.map((a) => `${a.id}:${(a.orderbook && a.orderbook.ok) ? 1 : 0}`).join(','),
cc.combo_id || '',
cc.ok ? 1 : 0,
topn.apply_overfit_pct != null ? topn.apply_overfit_pct : '',
topn.run_ob_whipsaw ? 1 : 0,
].join('|');
}
function optunaRenderPostprocess(_sum) {
// 호가 8방·후처리 비교표 UI 제거 — no-op (백엔드 postprocess_topn 은 유지)
}
function optunaRenderTop5(sum) {
const ban = $('opt_tpe_ob_banner');
if (ban) {
if (sum && sum.tpe_includes_orderbook) {
const postOff = sum.post_run_ob_whipsaw === false;
ban.innerHTML = postOff
? '
본 TPE에 호가·휩쏘 축 포함 '
+ ' · 표
호가 열 = trial마다 ON/OFF·스프레드 (상세 안 눌러도 됨)'
+ ' ·
사후 호가8방이 비는 것은 정상(사후탐색 OFF) '
: '
본 TPE에 호가 축 포함 · 표
호가 열 참고'
+ (sum.post_run_ob_whipsaw ? ' · 사후8방도 돌림' : '');
ban.className = 'small mb-2';
} else if (sum) {
ban.innerHTML = '
이 JSON은 본 TPE 호가축 없음(구잡) · 호가열 — · 사후8방은 「후처리 재실행」 ';
ban.className = 'small mb-2 text-muted';
} else {
ban.textContent = '—';
ban.className = 'small mb-2 text-muted';
}
}
optunaFillRankTbody(
'opt_top5_learn_tbody',
(sum && sum.top5_learn) || [],
'learn',
'학습 후보 없음 / 미완료',
);
optunaFillRankTbody(
'opt_top5_tbody',
(sum && sum.top5_gated) || [],
'gated',
'gated 후보 없음 / 미완료',
);
optunaRenderTop5Stable(sum);
// mode 표는 optunaRenderModeCombo 에서도 채움(중복 OK · 요약 카드와 동기)
optunaFillRankTbody(
'opt_top5_mode_tbody',
(sum && sum.top5_mode) || [],
'mode',
'mode 실측 없음 / 미완료',
);
}
function optunaRenderTop5Stable(sum) {
optunaFillRankTbody(
'opt_top5_stable_tbody',
(sum && sum.top5_stable) || [],
'stable',
'stable 후보 없음 (일별 필드 없는 구 JSON · 재실행 필요)',
);
}
function optunaHighlightGated(rank) {
optunaShowCandidate('gated', rank || 1);
window._optunaPostSel = { source: 'gated', rank: rank || 1 };
optunaRenderPostprocess(window._optunaLastSummary || null);
}
async function optunaShowCandidate(source, rank) {
if (!_optunaJobId) {
alert('선택된 job 없음 — 최근 잡에서「보기」로 job을 먼저 고르세요');
return;
}
const src = source || 'gated';
const rk = rank || 1;
const title = $('opt_detail_title');
const met = $('opt_detail_metrics');
const pre = $('opt_detail_params');
if (title) title.textContent = `로딩 ${src} #${rk}…`;
if (met) met.textContent = '불러오는 중…';
try {
const qs = new URLSearchParams({
job_id: _optunaJobId,
source: src,
rank: String(rk),
});
const r = await fetch('/api/optuna/candidate?' + qs.toString());
const j = await r.json();
if (!j.ok) {
if (met) met.textContent = '❌ ' + (j.error || '실패');
if (pre) pre.textContent = '—';
return;
}
const m = j.metrics || {};
if (title) {
title.textContent =
`${j.strategy || ''} · ${m.label || src} · trial ${m.optuna_trial_number != null ? '#' + m.optuna_trial_number : '—'}`;
}
if (met) {
const daily = m.daily_pnl && typeof m.daily_pnl === 'object'
? Object.keys(m.daily_pnl).sort().map(d => `${d}:${Number(m.daily_pnl[d]).toLocaleString()}`).join(' · ')
: '';
met.innerHTML =
`거래
${optunaFmtNum(m.total_trades, 0)} · ` +
`승률
${optunaFmtNum(m.win_rate, 1)}% · ` +
`PF
${optunaFmtNum(m.pf, 2)} · ` +
`PnL
${optunaFmtNum(m.total_pnl)} ` +
(m.stability_score != null
? ` · 안정
${optunaFmtNum(m.stability_score, 2)} (높을수록 좋음·만점없음 · 손실일 ${optunaFmtNum(m.n_losing_days, 0)} · 최악 ${optunaFmtNum(m.worst_day_pnl)})`
: '') +
(m.overfit_risk_pct != null
? ` · 과적합
${optunaFmtNum(m.overfit_risk_pct, 1)}% (높을수록 위험·0~100${m.overfit_verdict_ui ? ' · ' + m.overfit_verdict_ui : ''})`
: '') +
` · 파라미터 ${j.params_count || 0}개` +
(j.start && j.end ? ` · 기간 ${j.start}~${j.end}` : '') +
(daily ? `
일별 PnL: ${daily}
` : '') +
`
폼에 반영 ` +
`
백테탭 ` +
`
이 후보 DB적용 `;
}
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 optunaFmtPeriodShort(start, end) {
const s = String(start || '').slice(0, 10);
const e = String(end || '').slice(0, 10);
return (s && e) ? `${s}~${e}` : (s || e || '—');
}
function optunaPeriodBadge(label, start, end, extraCls) {
const r = optunaFmtPeriodShort(start, end);
return ``;
}
function optunaRenderPeriodBanner(job) {
const el = $('opt_period_banner');
if (!el) return;
const pi = job && job.period_info;
const formS = ($('opt_start')?.value || '').trim().slice(0, 10);
const formE = ($('opt_end')?.value || '').trim().slice(0, 10);
const jobS = String((job && job.start) || '').slice(0, 10);
const jobE = String((job && job.end) || '').slice(0, 10);
if (!pi && !jobS) {
el.innerHTML = '';
el.className = 'opt-period-banner d-none mb-2 p-2 rounded small';
return;
}
const formMismatch = !!(formS && formE && jobS && jobE && (formS !== jobS || formE !== jobE));
const hasRefine = !!(pi && pi.has_refine);
const mismatch = !!(pi && pi.mismatch) || formMismatch;
el.className = 'opt-period-banner mb-2 p-2 rounded small ' + (mismatch ? 'opt-period-warn' : 'opt-period-ok');
let html = mismatch
? '
⚠ 기간이 서로 다릅니다 — Top5·일평균·백테 비교 시 study별 기간을 확인하세요
'
: '
📅 백테 기간 (전 단계 동일)
';
html += '
';
if (formS && formE) {
const fm = formMismatch ? ' opt-period-badge-warn' : '';
html += optunaPeriodBadge('상단 폼', formS, formE, 'opt-period-badge-form' + fm);
}
if (jobS && jobE) {
html += optunaPeriodBadge('잡(메인)', jobS, jobE, 'opt-period-badge-master');
}
if (hasRefine && pi) {
if (pi.phase1 && pi.phase1.start) {
const c = (pi.phase1.start !== jobS || pi.phase1.end !== jobE) ? ' opt-period-badge-warn' : '';
html += optunaPeriodBadge('1차 TPE', pi.phase1.start, pi.phase1.end, 'opt-period-badge-p1' + c);
}
if (pi.phase2 && pi.phase2.study && pi.phase2.start) {
const c = (pi.phase2.start !== jobS || pi.phase2.end !== jobE) ? ' opt-period-badge-warn' : '';
html += optunaPeriodBadge('2차 TPE', pi.phase2.start, pi.phase2.end, 'opt-period-badge-p2' + c);
}
if (pi.active && pi.active.study && pi.active.start) {
html += optunaPeriodBadge('활성 study', pi.active.start, pi.active.end, 'opt-period-badge-active');
}
} else if (pi && pi.active && pi.active.study && pi.active.start) {
html += optunaPeriodBadge('study', pi.active.start, pi.active.end, 'opt-period-badge-active');
}
html += '
';
if (formMismatch) {
html += `
상단 폼(${formS}~${formE}) ≠ 선택 잡(${jobS}~${jobE}) — 새 잡 시작 시 폼 기간이 적용됩니다
`;
}
if (pi && pi.mismatch_notes && pi.mismatch_notes.length) {
html += `
${pi.mismatch_notes.join(' · ')}
`;
}
if (pi && pi.refine_note) {
html += `
${pi.refine_note}
`;
}
el.innerHTML = html;
}
function optunaFormatJobPeriodCell(job) {
const pi = job.period_info;
const base = optunaFmtPeriodShort(job.start, job.end);
if (!pi || !pi.has_refine) return base;
if (!pi.mismatch) return base;
const p1 = pi.phase1 && pi.phase1.range ? pi.phase1.range : '';
const p2 = (pi.phase2 && pi.phase2.study && pi.phase2.range) ? pi.phase2.range : '';
let inner = '
⚠ ' + base;
if (p1 && p1 !== base) inner += `
1차 ${p1} `;
if (p2 && p2 !== base && p2 !== p1) inner += `
2차 ${p2} `;
return inner;
}
function optunaRenderJob(job) {
if (!job) return;
if (job.job_id) {
_optunaJobId = job.job_id;
try { localStorage.setItem(OPTUNA_LS_KEY, job.job_id); } catch (e) { /* ignore */ }
}
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')) {
const stEl = $('opt_st_status');
stEl.textContent = job.status || '—';
stEl.style.cursor = 'pointer';
stEl.title = '클릭하면 다른 PC 실행 명령';
stEl.onclick = function () { optunaOpenJoinCmd(job.job_id); };
}
if ($('opt_st_job')) $('opt_st_job').textContent = job.job_id || '—';
if ($('opt_st_study')) {
$('opt_st_study').textContent = job.active_study_name || job.study_name || '—';
}
const refineWrap = $('opt_st_refine_wrap');
const refineEl = $('opt_st_refine');
const piRef = job.period_info;
if (refineWrap && refineEl) {
if (piRef && piRef.has_refine && piRef.phase1 && piRef.phase2) {
const p1r = piRef.phase1.range || optunaFmtPeriodShort(piRef.phase1.start, piRef.phase1.end);
const p2r = piRef.phase2.range || optunaFmtPeriodShort(piRef.phase2.start, piRef.phase2.end);
const p1s = (piRef.phase1.study || '').split('_').slice(-3, -1).join('_') || '1차';
refineEl.innerHTML =
`
1차 ${(piRef.phase1.study || '—').slice(0, 48)}` +
`
${p1r} ` +
` ·
2차 ${(piRef.phase2.study || '—').slice(0, 48)}` +
`
${p2r} `;
refineWrap.classList.remove('d-none');
} else {
refineEl.textContent = '—';
refineWrap.classList.add('d-none');
}
}
if ($('opt_st_trials')) {
const met = optunaBestMetricsText(prog, job);
const nowBits = [
job.current_strategy,
job.current_entry_mode,
].filter(Boolean).join('/');
const seqBits = (prog.seq_step && prog.seq_steps)
? `단계 ${prog.seq_step}/${prog.seq_steps}`
: '';
$('opt_st_trials').textContent =
(() => {
const done = prog.trials_done ?? '—';
const batch = prog.trials_total ?? job.trials ?? '—';
const goal = job.study_trials || 0;
const nDone = job.n_complete != null ? job.n_complete : done;
// 분모=이번 배치(100)면 126/100 처럼 보임 → 목표가 있으면 목표 기준으로 표시
if (goal > 0) {
const p2 = Math.min(100, Math.round((Number(nDone || 0) / goal) * 1000) / 10);
return `누적 ${nDone}/${goal} (${p2}%) · 이번배치 ${batch}`;
}
return `${done} / ${batch} (${pct}%)`;
})() +
(seqBits ? ` · ${seqBits}` : '') +
(nowBits ? ` · now=${nowBits}` : '') +
(met ? ` · ${met}` : '') +
((job.status === 'running' && Number(prog.trials_done || 0) === 0)
? ' · 준비(캔들·틱 로드) — Top5는 trial 시작 후'
: '');
}
const post = job.postprocess || {};
const postFill = $('opt_post_fill');
const postPct = post.ready ? 100 : Number(post.pct || 0);
if (postFill) {
postFill.style.width = `${Math.min(100, postPct)}%`;
postFill.classList.toggle('is-done', !!post.ready);
postFill.classList.toggle('is-error', false);
}
if ($('opt_st_post')) {
const busy = optunaPostBusy(job);
$('opt_st_post').textContent = post.hint
|| (busy ? '후처리 중' : (job.status === 'done' ? '후처리 —' : '후처리 — trial 끝난 뒤 시작'));
}
if ($('opt_btn_mode_detail')) $('opt_btn_mode_detail').disabled = optunaPostBusy(job);
if ($('opt_btn_pp_rerun')) $('opt_btn_pp_rerun').disabled = optunaPostBusy(job);
if ($('opt_st_log')) $('opt_st_log').textContent = job.display_log_path || job.log_path || '—';
if ($('opt_st_json')) {
const notes = job.result_json_notes || [];
if (notes.length > 1) {
$('opt_st_json').textContent = notes.map(n => {
const name = String(n.path || '').split('/').pop();
return `${name}(n=${n.n_all})`;
}).join(' · ');
} else {
$('opt_st_json').textContent = job.result_json || '—';
}
}
const isSeq = job.kind === 'seq' || job.kind === 'seq4';
const kids = job.child_jobs || [];
const sum = (isSeq && kids.length) ? null : (job.result_summary || null);
window._optunaLastSummary = sum || null;
window._optunaLastJob = job;
if ($('opt_st_top')) {
if (isSeq && kids.length) {
$('opt_st_top').innerHTML =
'
순차 묶음 — 적용은 전략별 잡에서. 버튼을 누르면 그 전략 Top5가 나옵니다.
' +
kids.map(k => {
const jid = String(k.job_id || '').replace(/'/g, '');
const lab = k.label || k.strategy || jid;
return `
${lab} · gated ${k.n_gated ?? '—'} / ${k.n_all ?? '—'} `;
}).join('');
} else if (job.status === 'running' && job.live_summary && (job.live_summary.top3_learn || []).length) {
const ls = job.live_summary;
const phase = ls.refine_phase === 'phase2' ? '2차' : '1차';
const pr = ls.period_range || optunaFmtPeriodShort(ls.period_start, ls.period_end);
let html = `
${phase} learn Top3 · 기간 ${pr} (진행 중 · 축 분포 확인용)
`;
(ls.top3_learn || []).forEach((t, i) => {
html += `
${optunaFormatTopRow(t, i + 1)}
`;
});
if (ls.refine_phase === 'phase2' && (ls.phase1_top3 || []).length) {
const p1pr = ls.phase1_period_range || '';
html += `
1차 Top3 (참고${p1pr ? ' · 기간 ' + p1pr : ''})
`;
ls.phase1_top3.forEach((t, i) => {
html += `
${optunaFormatTopRow(t, i + 1)}
`;
});
}
$('opt_st_top').innerHTML = html;
window._optunaLastSummary = { top5_learn: ls.top3_learn || [], n_all: ls.n_complete };
optunaRenderTop5(window._optunaLastSummary);
} else if (sum && sum.top) {
const t = sum.top;
const dAvg = (t.period_daily_avg_pnl != null)
? t.period_daily_avg_pnl
: t.daily_pnl_mean;
const dPct = (t.period_daily_avg_pct != null)
? t.period_daily_avg_pct
: t.daily_avg_pct;
const mtBit = (sum.min_trades != null)
? ` · min≥${sum.min_trades}` +
(sum.n_trading_days != null
? `(${sum.n_trading_days}일×${sum.min_trades_per_day || 4})`
: '')
: (job.min_trades != null
? ` · min≥${job.min_trades}` +
(job.n_trading_days != null
? `(${job.n_trading_days}일×${job.min_trades_per_day || 4})`
: '')
: '');
$('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()}원` +
(dAvg != null ? ` · 일평균 ${Number(dAvg).toLocaleString()}원` : '') +
(dPct != null ? ` · 일평균 ${Number(dPct) >= 0 ? '+' : ''}${Number(dPct).toFixed(3)}%` : '') +
` · gated ${sum.n_gated}/${sum.n_all}` +
(sum.n_stable != null ? ` · stable ${sum.n_stable}` : '') +
mtBit;
} else if (job.status === 'done') {
$('opt_st_top').textContent = job.error
|| '완료 · 학습/게이트 통과 0건 (이 스터디는 표가 비는 것이 정상)';
} 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 || (isSeq && kids.length));
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);
optunaRenderGatedTop3Card(sum, job);
if (!window._optunaPostSel) window._optunaPostSel = { source: 'gated', rank: 1 };
optunaRenderPostprocess(sum);
optunaRenderPeriodBanner(job);
optunaSetNav(job);
}
async function optunaBacktestFromCandidate(source, rank) {
if (!_optunaJobId) {
alert('선택된 job 없음 — 최근 잡에서「보기」로 job을 먼저 고르세요');
return;
}
const src = source || 'gated';
const rk = rank || 1;
if (src === 'mode') {
alert('mode_combo는 trial 번호가 없어 백테탭 자동 채우기를 지원하지 않습니다.');
return;
}
const curJob = window._optunaLastJob;
if (curJob && (curJob.kind === 'seq' || curJob.kind === 'seq4') && (curJob.child_jobs || []).length) {
alert('순차 묶음 잡입니다. 전략별 import 잡에서「백테」를 누르세요.');
return;
}
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) {
alert('❌ ' + (j.error || '후보 없음'));
return;
}
window._optunaCandidateCache = {
strategy: j.strategy,
start: j.start,
end: j.end,
params: j.params_full || j.params_preview || {},
metrics: j.metrics || {},
};
const cache = window._optunaCandidateCache;
const strat = String(cache.strategy || curJob?.strategy || '').toLowerCase();
const p = cache.params || {};
const job = curJob || {};
const trialNo = (j.metrics && j.metrics.optuna_trial_number != null)
? j.metrics.optuna_trial_number
: '—';
const applyJobSources = (cfg) => {
if (cache.start && cfg.startId) setDateVal(cfg.startId, cache.start);
if (cache.end && cfg.endId) setDateVal(cfg.endId, cache.end);
const hist = job.universe_history_source
|| $('opt_univ_history_source')?.value
|| 'kiwoom';
if (cfg.univSrcId && $(cfg.univSrcId)) $(cfg.univSrcId).value = hist;
if (cfg.univId && $(cfg.univId)) $(cfg.univId).checked = true;
if (job.candle_source && cfg.candleId && $(cfg.candleId)) {
$(cfg.candleId).value = job.candle_source;
}
if (job.tick_source && cfg.tickId && $(cfg.tickId)) {
$(cfg.tickId).value = job.tick_source;
}
if (job.ob_source && cfg.obId && $(cfg.obId)) {
$(cfg.obId).value = job.ob_source;
}
};
const tabMap = {
breakout: 'breakout',
tail: 'tail',
scalp: 'backtest',
momentum: 'momentum',
us_momentum: 'us_momentum',
};
const tabLabel = {
breakout: '돌파매매',
tail: '꼬리잡기',
scalp: '스캘핑',
momentum: '모멘텀',
us_momentum: '해외 모멘텀',
};
if (strat === 'momentum' || strat === 'us_momentum') {
await optunaFillFormFromCandidate(src, rk);
return;
}
if (strat === 'breakout') {
applyJobSources({
startId: 'bo_start',
endId: 'bo_end',
univId: 'bo_use_univ_history',
univSrcId: 'bo_univ_history_source',
candleId: 'bo_candle_source',
tickId: 'bo_tick_source',
obId: 'bo_ob_source',
});
fillBreakoutFormFromOptunaParams(p);
} else if (strat === 'tail') {
applyJobSources({
startId: 'tl_start',
endId: 'tl_end',
univId: 'tl_use_univ_history',
univSrcId: 'tl_univ_history_source',
tickId: 'tl_tick_source',
obId: 'tl_ob_source',
});
fillTailFormFromOptunaParams(p);
} else if (strat === 'scalp') {
applyJobSources({
startId: 'bt_start',
endId: 'bt_end',
univId: 'bt_use_univ_history',
univSrcId: 'bt_univ_history_source',
tickId: 'bt_tick_source',
obId: 'bt_ob_source',
});
fillScalpFormFromOptunaParams(p);
} else {
alert('백테탭 자동 채우기 미지원 전략: ' + strat);
return;
}
const wantTab = tabMap[strat] || strat;
const tab = document.querySelector(`[data-tab="${wantTab}"]`);
if (tab && !tab.classList.contains('active')) tab.click();
const panelId = wantTab === 'backtest' ? 'tab-backtest' : ('tab-' + wantTab);
const panel = $(panelId);
if (panel) panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
alert(
`${tabLabel[strat] || strat} 백테 탭에 반영했습니다.\n` +
`trial #${trialNo} · ${src} #${rk}\n` +
`기간 ${cache.start || ''}~${cache.end || ''}\n` +
'「백테 실행」으로 Optuna 숫자와 비교하세요.\n(DB 저장 아님)'
);
} catch (e) {
alert('백테 폼 반영 오류: ' + e);
}
}
function fillBreakoutFormFromOptunaParams(p) {
if (!p || typeof p !== 'object') return;
const b = Object.assign({}, p);
if (b.ob_filter_enabled == null && b._orderbook_filter_enabled != null) {
b.ob_filter_enabled = !!b._orderbook_filter_enabled;
}
if (b.pg_filter_enabled == null && b._program_filter_enabled != null) {
b.pg_filter_enabled = !!b._program_filter_enabled;
}
fillBreakoutFormFromApi(b);
if (typeof fillObWhipReadonly === 'function') fillObWhipReadonly('bo', b);
}
function fillTailFormFromOptunaParams(p) {
if (!p || typeof p !== 'object') return;
fillTailFormFromApi(p);
if (typeof fillObWhipReadonly === 'function') fillObWhipReadonly('tl', p);
}
function fillScalpFormFromOptunaParams(p) {
if (!p || typeof p !== 'object') return;
fillScalpFormFromApi(p);
if (typeof fillObWhipReadonly === 'function') fillObWhipReadonly('bt', p);
}
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) {
// Top10「적용」= 그 줄 trial 전체(타점·익절·손절·호가ON/OFF·휩쏘). 구 base=호가OFF 덮어쓰기.
return optunaApplyUpto(source, rank, 'full');
}
async function optunaApplyTrail() {
return optunaApplyUpto('gated', 1, 'trail');
}
async function optunaApplyUpto(source, rank, upto) {
if (!_optunaJobId) { alert('선택된 job 없음 — 최근 잡에서 보기 먼저'); return; }
const curJob = window._optunaLastJob;
if (curJob && (curJob.kind === 'seq' || curJob.kind === 'seq4') && (curJob.child_jobs || []).length) {
const lines = (curJob.child_jobs || []).map(k => `· ${k.label || k.strategy}`).join('\n');
alert('순차 묶음 잡입니다.\n전략별 잡에서「보기」한 뒤 적용하세요.\n' + lines);
return;
}
const src = source || 'gated';
const rk = rank || 1;
const u = upto || 'base';
const labels = {
full: '줄전체(타점·익절·손절·호가·휩쏘)',
as_scored: '줄전체(타점·익절·손절·호가·휩쏘)',
trial: '줄전체(타점·익절·손절·호가·휩쏘)',
all: '줄전체(타점·익절·손절·호가·휩쏘)',
base: OPTUNA_OB_COMBO_LABELS.base,
e: OPTUNA_OB_COMBO_LABELS.e,
x: OPTUNA_OB_COMBO_LABELS.x,
s: OPTUNA_OB_COMBO_LABELS.s,
ex: OPTUNA_OB_COMBO_LABELS.ex,
es: OPTUNA_OB_COMBO_LABELS.es,
xs: OPTUNA_OB_COMBO_LABELS.xs,
exs: OPTUNA_OB_COMBO_LABELS.exs,
entry: OPTUNA_OB_COMBO_LABELS.entry,
exit: OPTUNA_OB_COMBO_LABELS.exit,
stop: OPTUNA_OB_COMBO_LABELS.stop,
whipsaw: '휩쏘 ON(호가 111은 전략별)',
trail: '합의 다단트레일만(타점 미변경)',
'000': OPTUNA_OB_COMBO_LABELS['000'],
'100': OPTUNA_OB_COMBO_LABELS['100'],
'010': OPTUNA_OB_COMBO_LABELS['010'],
'001': OPTUNA_OB_COMBO_LABELS['001'],
'110': OPTUNA_OB_COMBO_LABELS['110'],
'101': OPTUNA_OB_COMBO_LABELS['101'],
'011': OPTUNA_OB_COMBO_LABELS['011'],
'111': OPTUNA_OB_COMBO_LABELS['111'],
};
const isFull = (u === 'full' || u === 'as_scored' || u === 'trial' || u === 'all');
// 방+휩쏘 / 호가만 라벨
if (String(u).includes('whip')) {
labels[u] = `${String(u).replace(/\+whip(saw)?$/i, '')}방+휩쏘 DB적용(탐색아님)`;
} else if (isFull) {
labels[u] = labels[u] || '줄전체';
} else if (u !== 'base' && u !== 'trail' && u !== '000') {
// 「호가만」= 호가 수치만 · 휩쏘는 OFF+수치 비움 (남겨두면 안 됨)
labels[u] = (labels[u] || u) + ' · 휩쏘 OFF·수치비움';
} else if (u === 'base' || u === '000') {
labels[u] = (labels[u] || u) + ' · 휩쏘 OFF·수치비움';
}
let extra = '';
try {
const sum = window._optunaLastSummary || null;
const ofKey = src === 'stable' ? 'top5_stable' : (src === 'learn' ? 'top5_learn' : 'top5_gated');
const ofRow = ((sum && sum[ofKey]) || []).find((x) => Number(x.rank) === Number(rk));
const ofPct = ofRow && ofRow.overfit_risk_pct != null
? ofRow.overfit_risk_pct
: (sum && (sum.apply_overfit_pct != null
? sum.apply_overfit_pct
: (sum.postprocess_topn && sum.postprocess_topn.apply_overfit_pct)));
if (ofPct != null && ofPct !== '') {
extra += `\n\n이 후보 과적합 ${Number(ofPct)}% (0~100 · 높을수록 위험 · 추정).`;
}
if (isFull) {
extra += '\n\n이 줄 Top 요약에 나온 타점·익절·손절·호가ON/OFF·스프레드·잔량·ask×·휩쏘를 그대로 DB에 넣습니다.\n(사후 8방으로 호가OFF 덮어쓰지 않음)';
} else if (u !== 'trail') {
extra += '\n\n선택한 방의 켠 축만 ON, 나머지 호가축은 ENABLED=false (숫자는 DB에 남김).';
if (!String(u).includes('whip')) {
extra += '\n휩쏘는 OFF로 끄고 sub/lookback/dip 수치도 비웁니다(호가만=휩쏘 남기지 않음).';
}
}
if (u === 'trail') {
const rec = sum && (sum.daily_trail_recommend
|| (sum.postprocess_topn && sum.postprocess_topn.postprocess_consensus
&& sum.postprocess_topn.postprocess_consensus.trail));
if (rec && rec.ok) {
extra += `\n\n${rec.prefix}_DAILY_PROFIT_TRAIL_TIERS=${rec.tiers}\nARM=${Number(rec.arm_krw || 0).toLocaleString()}원`;
}
}
} catch (e) { /* ignore */ }
const who = (src === 'mode' ? 'mode_combo(축최빈조립·trial없음)' : (src === 'consensus' ? `mode Top10 #${rk}` : (src === 'stable' ? `안정 #${rk}` : (src === 'learn' ? `학습 #${rk}` : `사후합격 #${rk}`))));
// 적용 직전: 익절/손절 숫자를 confirm에 보여 혼동(mode vs gated) 방지
try {
const sum2 = window._optunaLastSummary || null;
let pr = null;
if (src === 'mode') {
const mr = ((sum2 && sum2.compare_rows) || []).find((r) => r.source === 'mode');
pr = (mr && (mr.params || mr)) || (sum2 && sum2.mode_combo_summary && sum2.mode_combo_summary.params) || null;
} else {
const ofKey2 = src === 'stable' ? 'top5_stable' : (src === 'consensus' ? 'top5_consensus' : (src === 'learn' ? 'top5_learn' : 'top5_gated'));
const row2 = ((sum2 && sum2[ofKey2]) || []).find((x) => Number(x.rank) === Number(rk));
pr = row2 && (row2.params || row2);
}
if (pr && (pr.tp_pct != null || pr.sl_pct != null || pr.tp_max_pct != null)) {
extra += `\n\n저장될 값(UI%): tp=${pr.tp_pct ?? '—'} · sl=${pr.sl_pct ?? '—'} · tmax=${pr.tp_max_pct ?? '—'}`;
}
if (src === 'mode') {
extra += '\n※ mode=TopN 축별 최빈 조립(한 trial이 아님). 사후합격#1과 숫자가 다를 수 있음.';
}
} catch (e2) { /* ignore */ }
const msg = `${who} · ${labels[u] || u} 을 DB에 적용할까요?\n(TIME_* 미변경 · 실매 즉시 영향 · 적용 로그 남김)` + extra;
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, upto: u }),
});
const j = await r.json();
if (!j.ok) { alert('❌ ' + (j.error || '적용 실패')); return; }
const m = j.metrics || {};
const keys = Object.keys(j.axis_patch || {});
const strat = String(j.strategy || '').toLowerCase();
const stratLabel = ({
scalp: '스캘핑', momentum: '모멘텀', tail: '꼬리', breakout: '돌파', us_momentum: '해외모멘텀',
})[strat] || strat || '전략';
// 1) axis_patch 즉시 페인트 → 2) fresh DB 전체 폼 동기화 (새로고침 불필요)
try { optunaPaintAxisPatchOnForms(strat, j.axis_patch || {}); } catch (e0) { /* ignore */ }
let formHint = '';
try {
const d = await reloadStrategyFormsFromDb(strat);
if (strat === 'momentum' && d && d.momentum) {
const mm = d.momentum;
formHint = `\n폼←DB: 스프레드≤${mm.max_spread_pct ?? '—'} · 잔량≥${mm.ob_min_bid_ask_ratio ?? '—'} · 휩쏘=${mm.whipsaw_filter_enabled ? 'ON' : 'OFF'}`;
} else if (strat === 'breakout' && d && d.breakout) {
const bb = d.breakout;
formHint = `\n폼←DB: 스프레드≤${bb.max_spread_pct ?? '—'} · 호가=${bb.ob_filter_enabled ? 'ON' : 'OFF'}`;
} else if (strat === 'tail' && d && d.tail) {
const tt = d.tail;
formHint = `\n폼←DB: 스프레드≤${tt.max_spread_pct ?? '—'} · 호가=${tt.ob_filter_enabled ? 'ON' : 'OFF'}`;
} else if (strat === 'scalp') {
const sc = (d && d.scalp) || {};
formHint = `\n폼←DB: 손절${sc.sl_pct ?? '—'}% · 익절${sc.tp_pct ?? '—'}% · 호가=${sc.ob_filter_enabled ? 'ON' : 'OFF'}`
+ ` spr≤${sc.max_spread_pct ?? '—'} · 휩쏘=${sc.whipsaw_filter_enabled ? 'ON' : 'OFF'}`;
}
} catch (e2) {
formHint = '\n⚠️ 폼 DB 동기화 실패 — 새로고침 후 확인';
console.warn('optuna apply form reload', e2);
}
const tabMap = {
scalp: 'backtest', momentum: 'momentum', tail: 'tail',
breakout: 'breakout', us_momentum: 'us_momentum',
};
const wantTab = tabMap[strat];
if (wantTab) {
const tab = document.querySelector(`[data-tab="${wantTab}"]`);
if (tab && !tab.classList.contains('active')) tab.click();
}
alert(
`✅ ${stratLabel} 적용 완료 (upto=${j.upto || u})` +
(j.env_id != null ? ` (env_id=${j.env_id})` : '') +
`\ntrial #${m.optuna_trial_number ?? '—'} · PnL ${m.total_pnl} · WR ${m.win_rate}` +
(keys.length ? `\n후처리키: ${keys.slice(0, 12).join(', ')}${keys.length > 12 ? '…' : ''}` : '') +
`\n${j.note || ''}` +
formHint +
`\n→ ${stratLabel} 백테 탭 폼을 DB로 다시 채웠습니다(새로고침 불필요).`,
);
} catch (e) {
alert('오류: ' + e);
}
}
async function optunaRerunPostprocess() {
const jid = _optunaJobId || (window._optunaLastJob && window._optunaLastJob.job_id) || '';
if (!jid) { alert('선택된 job 없음 — 최근 잡 목록에서 해당 잡을 누르세요'); return; }
_optunaJobId = jid;
try { localStorage.setItem(OPTUNA_LS_KEY, jid); } catch (e) { /* ignore */ }
if (!confirm('이 잡 JSON에 후처리(진입/익절/손절/휩쏘)를 다시 붙일까요?\n실매 DB는 안 바뀝니다. 백그라운드 실행.\n호가스냅이 3건 미만이면 8방 숫자는 그대로일 수 있습니다.')) return;
try {
const r = await fetch('/api/optuna/postprocess/rerun', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ job_id: jid }),
});
const j = await r.json();
if (!j.ok) { alert('❌ ' + (j.error || '실패')); return; }
alert('후처리 재실행 시작\n로그: ' + (j.log_path || '') + '\n끝나면 새로고침하면 표가 채워집니다.');
if ($('opt_pp_rerun_status')) {
$('opt_pp_rerun_status').textContent = '재실행 중… ' + (j.log_path || '');
}
} 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);
const st = j.running[0].status || 'running';
const prev = _optunaPollStatus;
_optunaPollStatus = st;
optunaRenderJob(j.running[0]);
await optunaRefreshJobsIfNeeded(prev !== st);
return;
}
if (j.latest) {
if (j.latest.job_id) {
_optunaJobId = j.latest.job_id;
localStorage.setItem(OPTUNA_LS_KEY, _optunaJobId);
}
const st = j.latest.status || '';
const prev = _optunaPollStatus;
_optunaPollStatus = st;
optunaRenderJob(j.latest);
if (j.latest.status === 'done' || j.latest.status === 'error') {
optunaSetNav(j.latest);
}
if (prev !== st || st === 'running') await optunaRefreshJobsIfNeeded(prev !== st && (st === 'done' || st === 'error'));
} 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 jid = _optunaJobId;
const r = await fetch('/api/optuna/status/' + encodeURIComponent(jid));
const j = await r.json();
if (jid !== _optunaJobId) return;
if (!j.ok) {
_optunaJobId = '';
localStorage.removeItem(OPTUNA_LS_KEY);
return;
}
const st = j.job.status || '';
const prev = _optunaPollStatus;
const finishedNow = (st === 'done' || st === 'error') && prev === 'running';
const busy = st === 'running' || optunaPostBusy(j.job);
_optunaPollStatus = st;
optunaRenderJob(j.job);
// 러닝 중·상태 변화·완료 직후 → 잡 목록도 자동 갱신 (수동 새로고침 불필요)
if (busy || finishedNow || prev !== st) {
await optunaRefreshJobsIfNeeded(finishedNow);
}
} catch (e) { /* ignore */ }
}
function optunaPollDelayMs() {
const job = window._optunaLastJob;
if (!job) return OPTUNA_POLL_MS_IDLE;
if (job.status === 'running' || optunaPostBusy(job)) return OPTUNA_POLL_MS_BUSY;
return OPTUNA_POLL_MS_IDLE;
}
function optunaStartPolling() {
if (_optunaPollTimer) {
clearTimeout(_optunaPollTimer);
clearInterval(_optunaPollTimer);
_optunaPollTimer = null;
}
const tick = async () => {
try {
await optunaPollOnce();
} catch (e) { /* ignore */ }
_optunaPollTimer = setTimeout(tick, optunaPollDelayMs());
};
tick();
}
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)
);
if (optunaTailEntryModes().length) checked.add('tail');
if (optunaBreakoutSlModes().length) checked.add('breakout');
return order.filter(s => checked.has(s));
}
function optunaPickKr4() {
const kr = new Set(['momentum', 'breakout', 'scalp']);
document.querySelectorAll('.opt-strat-cb').forEach(cb => {
cb.checked = kr.has(cb.value);
});
if ($('opt_tail_em_align')) $('opt_tail_em_align').checked = true;
if ($('opt_tail_em_limit')) $('opt_tail_em_limit').checked = false;
if ($('opt_bo_sl_fixed')) $('opt_bo_sl_fixed').checked = true;
if ($('opt_bo_sl_atr')) $('opt_bo_sl_atr').checked = false;
if ($('opt_bo_ob_off')) $('opt_bo_ob_off').checked = true;
if ($('opt_bo_ob_on')) $('opt_bo_ob_on').checked = false;
}
function optunaPickNone() {
document.querySelectorAll('.opt-strat-cb').forEach(cb => { cb.checked = false; });
document.querySelectorAll('.opt-tail-em').forEach(cb => { cb.checked = false; });
document.querySelectorAll('.opt-bo-sl').forEach(cb => { cb.checked = false; });
document.querySelectorAll('.opt-bo-ob').forEach(cb => { cb.checked = false; });
}
function optunaTailEntryModes() {
const out = [];
if ($('opt_tail_em_align')?.checked) out.push('align');
if ($('opt_tail_em_limit')?.checked) out.push('limit_atr');
return out;
}
function optunaBreakoutSlModes() {
const out = [];
if ($('opt_bo_sl_fixed')?.checked) out.push('fixed');
if ($('opt_bo_sl_atr')?.checked) out.push('atr');
return out;
}
function optunaBreakoutObModes() {
const out = [];
if ($('opt_bo_ob_off')?.checked) out.push('off');
if ($('opt_bo_ob_on')?.checked) out.push('on');
// 손절만 켠 채 호가 미체크면 OFF 스터디 1개 (기존 UX 유지)
return out.length ? out : ['off'];
}
async function optunaStart() {
const strategies = optunaSelectedStrategies();
const start = $('opt_start')?.value;
const end = $('opt_end')?.value;
const trials = parseInt($('opt_trials')?.value || '200', 10);
let studyTrials = parseInt($('opt_study_trials')?.value, 10);
if (!Number.isFinite(studyTrials) || studyTrials <= 0) studyTrials = null;
const mode = $('opt_mode')?.value || 'tpe';
const sortBy = $('opt_sort_by')?.value || 'score';
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 || '';
const entryModes = optunaTailEntryModes();
const slModes = optunaBreakoutSlModes();
const obModes = strategies.includes('breakout') ? optunaBreakoutObModes() : [];
const boCombos = strategies.includes('breakout')
? slModes.flatMap(sm => obModes.map(om => `${sm}×${om}`))
: [];
if (!strategies.length) { alert('전략을 1개 이상 체크하세요'); return; }
if (!start || !end) { alert('시작·종료일을 입력하세요'); return; }
const needSeq = strategies.length >= 2
|| (strategies.includes('tail') && entryModes.length >= 2)
|| (strategies.includes('breakout') && boCombos.length >= 2);
const how = needSeq
? `순차1·2차 (${[...strategies, ...(entryModes.length >= 2 ? ['꼬리×2'] : []), ...(boCombos.length >= 2 ? [`돌파×${boCombos.length}`] : [])].join('+')})`
: strategies[0];
const srcLabel = univSrc === 'ls' ? 'LS' : '키움';
const sortLabel = ({
score: '수익·낙폭·표본',
score_legacy: '(구) PnL/MDD하한',
daily_avg: '일평균',
pnl: '총손익',
})[sortBy] || sortBy;
const emLabel = strategies.includes('tail')
? `\n꼬리진입=${entryModes.join('+')}` + (entryModes.length >= 2 ? ' (순차·각 1·2차)' : '')
: '';
const slLabel = strategies.includes('breakout')
? `\n돌파=${boCombos.join('+')}` + (boCombos.length >= 2 ? ` (순차·각 1·2차)` : '')
: '';
const chainLabel = needSeq
? `\n각 전략/조합: 1차(넓은 Grid) → 2차(밴드 축소) 후 다음`
: `\n1·2차 TPE 연쇄 (1차 넓은 Grid → 2차 밴드 축소)`;
if (!confirm(`Optuna 시작?\n${how}: ${strategies.join(', ')}\n${start}~${end} trials=${trials}` +
(studyTrials ? `\n스터디 총 횟수=${studyTrials}` : '') +
`${chainLabel}\n승리식=${sortLabel}\n이력소스=${srcLabel}${emLabel}${slLabel}\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,
sort_by: sortBy,
study_trials: studyTrials || undefined,
universe_history_source: univSrc,
candle_source: candleSrc,
tick_source: tickSrc,
ob_source: obSrc,
entry_modes: strategies.includes('tail') ? entryModes : undefined,
sl_modes: strategies.includes('breakout') ? slModes : undefined,
ob_modes: strategies.includes('breakout') ? obModes : undefined,
}),
});
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 optunaContinue(jobId) {
const jid = jobId || _optunaJobId;
if (!jid) { alert('잡 없음'); return; }
if (!confirm('같은 study 이름으로 남은 횟수를 웹에서 이어 돌릴까요?\n(시작 버튼의 새 이름은 쓰지 않습니다)')) return;
try {
const r = await fetch('/api/optuna/continue', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ job_id: jid }),
});
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 optunaConfirmStudy(jobId) {
const jid = jobId || _optunaJobId;
if (!jid) { alert('잡 없음'); return; }
if (!confirm('지금 완료 수로 목표를 줄이고 후처리를 돌릴까요?\n윈도우/추가 웹 이어가기는 포기합니다. 실매 DB는 안 바뀝니다.')) return;
try {
const r = await fetch('/api/optuna/confirm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ job_id: jid }),
});
const j = await r.json();
if (!j.ok) { alert('❌ ' + (j.error || '실패')); return; }
alert('확정 · 후처리 시작\n' + (j.log_path || j.note || ''));
optunaRefreshJobs();
if (_optunaJobId) optunaPollOnce();
} 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); }
}
function optunaFormatModeTopRow(t, i) {
const dAvg = (t.period_daily_avg_pnl != null) ? t.period_daily_avg_pnl : t.daily_pnl_mean;
const dPct = (t.period_daily_avg_pct != null) ? t.period_daily_avg_pct : t.daily_avg_pct;
const prox = t.consensus_match_pct;
const proxN = t.consensus_match_n;
const proxOf = t.consensus_match_of;
let proxBit = '';
if (prox != null) {
proxBit = ` · 근접 ${Number(prox).toFixed(1)}%`;
if (proxN != null && proxOf != null) proxBit += ` (${proxN}/${proxOf}축)`;
}
return `#${i} trial ${t.optuna_trial_number ?? '—'} · PnL ${Number(t.total_pnl || 0).toLocaleString()}원` +
(dAvg != null ? ` · 일평균 ${Number(dAvg).toLocaleString()}원` : '') +
(dPct != null ? ` · 일평균 ${Number(dPct) >= 0 ? '+' : ''}${Number(dPct).toFixed(3)}%` : '') +
proxBit +
` · WR ${Number(t.win_rate || 0).toFixed(1)}% · PF ${Number(t.pf || 0).toFixed(2)}`;
}
function optunaBuildModeTop3Html(rows, opts) {
const o = opts || {};
const n = o.topN || OPTUNA_CARD_TOP_N;
const list = (rows || []).slice(0, n);
if (!list.length) return '';
const pr = o.periodRange ? ` · 기간
${o.periodRange} ` : '';
const pool = o.poolSize != null ? ` · pool ${o.poolSize}건` : '';
const head = o.title || `mode Top${n} (밴드 근접)`;
let html = `
${head}${pr}${pool}
`;
list.forEach((t, i) => {
html += `
${optunaFormatModeTopRow(t, i + 1)}
`;
});
return html;
}
function optunaFormatTopRow(t, i) {
const dAvg = (t.period_daily_avg_pnl != null) ? t.period_daily_avg_pnl : t.daily_pnl_mean;
const dPct = (t.period_daily_avg_pct != null) ? t.period_daily_avg_pct : t.daily_avg_pct;
return `#${i} trial ${t.optuna_trial_number ?? '—'} · PnL ${Number(t.total_pnl || 0).toLocaleString()}원` +
(dAvg != null ? ` · 일평균 ${Number(dAvg).toLocaleString()}원` : '') +
(dPct != null ? ` · 일평균 ${Number(dPct) >= 0 ? '+' : ''}${Number(dPct).toFixed(3)}%` : '') +
` · WR ${Number(t.win_rate || 0).toFixed(1)}% · PF ${Number(t.pf || 0).toFixed(2)} · score ${Number(t.score || 0).toFixed(3)}`;
}
async function optunaRefreshJobs() {
try {
const sort = $('opt_jobs_sort')?.value || 'started';
const r = await fetch('/api/optuna/jobs?limit=40&sort=' + encodeURIComponent(sort));
const j = await r.json();
const tb = $('opt_jobs_tbody');
if (!tb) return;
if (!j.ok || !(j.jobs || []).length) {
tb.innerHTML = '
잡 없음 ';
return;
}
tb.innerHTML = j.jobs.map(job => {
const prog = job.progress || {};
const jid = job.job_id;
const label = job.label || job.strategy || '';
const studyNote = (job.study_short && job.kind === 'import')
? `
${job.study_short} `
: (job.source === 'cli' && job.study_short
? `
${job.study_short} `
: '');
const sel = jid === _optunaJobId ? 'outline:1px solid var(--accent)' : '';
const st = String(job.status || '');
const refinePh = prog.refine_phase || '';
const phaseTag = refinePh === 'phase2' ? '2차' : (refinePh === 'phase1' ? '1차' : '');
const stCell = (st === 'running')
? `
${phaseTag ? phaseTag + ' ' : ''}running `
: st;
const statusNote = (st === 'running')
? (job.leftover_note ? ` ·${job.leftover_note}` : (phaseTag ? ` ·${phaseTag} TPE` : ''))
: (job.leftover_note ? ` ·${job.leftover_note}` : '');
const extraBtns = (st === 'done' && job.can_continue)
? `
이어 돌리기 ` +
`
확정 `
: '';
return `
${jid}
${label}${studyNote}
${optunaFormatJobPeriodCell(job)}
${stCell}${job.phase === 'postprocess' ? ' ·후처리' : ''}${statusNote}
${(() => {
const done = prog.trials_done ?? '—';
const batch = prog.trials_total ?? job.trials ?? '—';
const goal = job.study_trials;
const nDone = job.n_complete != null ? job.n_complete : done;
if (goal) return `${nDone}/${goal} ·배치${batch}`;
return `${done}/${batch}`;
})()}${job.postprocess && job.postprocess.pct != null && job.phase === 'postprocess' ? ' ·후 ' + job.postprocess.pct + '%' : ''}
${job.started_at || ''}
${job.finished_at || '—'}
보기
${extraBtns}
명령
`;
}).join('');
} catch (e) {
const tb = $('opt_jobs_tbody');
if (tb) tb.innerHTML = `
${e} `;
}
}
function optunaCloseJoinCmd() {
const el = $('opt_join_overlay');
if (el) el.classList.remove('is-open');
}
function optunaFillJoinOverlay(job) {
if (!job) return;
const pi = job.period_info;
let periodHint = '';
if (pi && (pi.has_refine || pi.active && pi.active.study)) {
const bits = [];
if (job.start && job.end) bits.push(`잡 ${optunaFmtPeriodShort(job.start, job.end)}`);
if (pi.phase1 && pi.phase1.start) bits.push(`1차 ${pi.phase1.range || optunaFmtPeriodShort(pi.phase1.start, pi.phase1.end)}`);
if (pi.phase2 && pi.phase2.study && pi.phase2.start) bits.push(`2차 ${pi.phase2.range || optunaFmtPeriodShort(pi.phase2.start, pi.phase2.end)}`);
if (bits.length) {
periodHint = (pi.mismatch ? '⚠ 기간 불일치 · ' : '📅 ') + bits.join(' · ');
}
}
if ($('opt_join_hint')) {
const base = job.join_hint || '';
$('opt_join_hint').textContent = periodHint ? (periodHint + (base ? '\n' + base : '')) : base;
}
if ($('opt_join_study')) {
$('opt_join_study').textContent = job.join_study || job.active_study_name || job.study_name || '—';
}
if ($('opt_join_cmd')) $('opt_join_cmd').textContent = job.join_cmd || '(아직 study 없음 · 첫 START 후)';
if ($('opt_join_cmd_ps')) $('opt_join_cmd_ps').textContent = job.join_cmd_ps || '(아직 study 없음 · 첫 START 후)';
if ($('opt_web_cmd_full')) {
$('opt_web_cmd_full').textContent = job.web_cmd_full || job.web_cmd || job.cmd || '—';
}
if ($('opt_web_cmd')) $('opt_web_cmd').textContent = job.web_cmd || job.cmd || '—';
const refineBox = $('opt_join_refine');
if (refineBox) {
const rows = job.seq_refine_cmds || [];
window._optunaSeqRefine = rows;
if (!rows.length) {
refineBox.innerHTML = '';
} else {
refineBox.innerHTML =
'
PC별 병렬 — 전략/조합마다 1·2차 (refine runner)
' +
'
MariaDB 공유 · PC마다 한 줄 · 2차만은 --phase1-study (DB payload)
' +
rows.map((row, i) => {
const done = row.phase1_db
? '
(1차 DB OK) '
: (row.done ? '
(1차 OK) ' : '');
const note = row.phase2_note ? `
${row.phase2_note}
` : '';
return `
${row.step}. ${row.label || row.strategy}${done}
1·2차 bash
1·2차 PS
2차만 bash
2차만 PS
${note}
`;
}).join('');
rows.forEach((row, i) => {
const pf = document.getElementById('opt_refine_full_' + i);
if (pf) pf.textContent = row.cmd || '';
const p2 = document.getElementById('opt_refine_p2_' + i);
if (p2) p2.textContent = row.cmd_phase2 || '';
});
}
}
const box = $('opt_join_all');
if (box) {
const all = job.join_cmds_all || [];
if (!all.length) {
box.innerHTML = '';
return;
}
window._optunaJoinAll = all;
box.innerHTML = '
스터디별 trial 추가 (1·2차 study 이름)
' +
all.map((row, i) => {
const lab = [row.strategy, row.extra].filter(Boolean).join('/');
return `
${i + 1}. ${lab} · ${row.study || ''}
bash
PS
`;
}).join('');
all.forEach((row, i) => {
const pre = document.getElementById('opt_join_all_' + i);
if (pre) pre.textContent = row.cmd || '';
const ps = document.getElementById('opt_join_all_ps_' + i);
if (ps) ps.textContent = row.cmd_ps || '';
});
}
}
function optunaCopySeqRefine(i, which) {
const row = (window._optunaSeqRefine || [])[i];
if (!row) return;
let t = row.cmd || '';
if (which === 'full_ps') t = row.cmd_ps || '';
if (which === 'p2') t = row.cmd_phase2 || '';
if (which === 'p2_ps') t = row.cmd_phase2_ps || '';
optunaCopyText(t);
}
function optunaCopyText(text) {
const t = String(text || '');
if (!t) return;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(t).then(() => {}, () => { prompt('복사:', t); });
} else {
prompt('복사:', t);
}
}
function optunaCopyJoinAll(i, which) {
const row = (window._optunaJoinAll || [])[i];
if (!row) return;
optunaCopyText(which === 'ps' ? (row.cmd_ps || '') : (row.cmd || ''));
}
function optunaCopyJoin(which) {
let el = $('opt_join_cmd');
if (which === 'web') el = $('opt_web_cmd');
if (which === 'web_full') el = $('opt_web_cmd_full');
if (which === 'ps') el = $('opt_join_cmd_ps');
optunaCopyText(el ? el.textContent : '');
}
async function optunaOpenJoinCmd(jobId) {
const jid = jobId || _optunaJobId || (window._optunaLastJob && window._optunaLastJob.job_id);
const ov = $('opt_join_overlay');
if (!ov) return;
if (!jid) { alert('선택된 잡 없음'); return; }
ov.classList.add('is-open');
if (window._optunaLastJob && window._optunaLastJob.job_id === jid) {
optunaFillJoinOverlay(window._optunaLastJob);
}
try {
const r = await fetch('/api/optuna/status/' + encodeURIComponent(jid));
const j = await r.json();
if (j.ok && j.job) optunaFillJoinOverlay(j.job);
else if (j.error) alert(j.error);
} catch (e) {
alert('명령 로드 실패: ' + e);
}
}
function optunaWatch(jobId) {
_optunaJobId = jobId;
localStorage.setItem(OPTUNA_LS_KEY, jobId);
if ($('opt_st_job')) $('opt_st_job').textContent = jobId;
const card = $('opt_status_card');
if (card) card.scrollIntoView({ behavior: 'smooth', block: 'start' });
document.querySelectorAll('#opt_jobs_tbody tr').forEach(tr => {
tr.style.outline = tr.getAttribute('data-job-id') === jobId ? '1px solid var(--accent)' : '';
});
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();
btRestoreTab();