feat(Core/UI): 백테스트 전역 엔진 선택 버튼 추가 및 스캘핑 Rust 방어로직 포팅 1차 완료

This commit is contained in:
Your Name
2026-09-03 02:49:14 +09:00
parent ac1190f1fc
commit 3b7c21cde4
316 changed files with 56952 additions and 1 deletions

496
scratch/restore_backtest.py Normal file
View File

@@ -0,0 +1,496 @@
import sys
path = "/home/hoon/kis_bot/static/js/backtest.js"
with open(path, "r", encoding="utf-8") as f:
content = f.read()
target_topn = 'const OPTUNA_CARD_TOP_N = 5;'
content = content.replace(target_topn, 'const OPTUNA_CARD_TOP_N = 10;')
target_gated = 'function optunaFormatGatedTopRowHtml(t, i, showBorder) {\n const dAvg ='
repl_gated = 'function optunaFormatGatedTopRowHtml(t, i, showBorder, stats) {\n const dAvg ='
content = content.replace(target_gated, repl_gated)
target_whip = ' <span>${optunaObWhipCell(t)}</span>'
repl_whip = ' <span>${optunaObWhipCell(t, stats)}</span>'
content = content.replace(target_whip, repl_whip)
# Add optunaCalcStats and optunaFormatAvgHtml
target_build = 'function optunaBuildGatedTop3Html(rows, opts) {'
repl_build = '''function optunaCalcStats(list) {
const keys = ['ob_spread', 'ob_ratio', 'ob_ask', 'tp_pct', 'tp_max_pct', 'sl_pct', 'whip_subbar_sec', 'whip_lookback_sec', 'whip_dip_pct', 'whip_recovery_tol_pct'];
const stats = {};
keys.forEach(k => {
let sum = 0, cnt = 0, min = Infinity, max = -Infinity;
list.forEach(r => {
if (r && r[k] != null) {
let v = Number(r[k]);
if ((k === 'whip_dip_pct' || k === 'whip_recovery_tol_pct') && v < 0.5) v *= 100;
sum += v; cnt++;
if (v < min) min = v;
if (v > max) max = v;
}
});
if (cnt > 0) {
const avg = sum / cnt;
let maxDist = Math.max(Math.abs(max - avg), Math.abs(min - avg));
stats[k] = { avg, min, max, maxDist };
}
});
return stats;
}
function optunaFormatAvgHtml(stats, len) {
if (!stats || Object.keys(stats).length === 0) return '';
const avgFmt = (k, prefix, suffix, dec) => {
if (!stats[k]) return '';
return `${prefix}${stats[k].avg.toFixed(dec)}${suffix} `;
};
return `<div class="mb-2 p-2 text-start" style="background:var(--bg-card);border:1px solid var(--border);border-radius:6px;font-size:11px;">
<div class="fw-bold mb-1" style="color:hotpink;">수치 평균 (Top${len} 기준 · 근접시 진하게)</div>
<div class="text-muted">
${stats['ob_spread'] ? `호가ON ` : ''}
${avgFmt('ob_spread', 'spr', '', 1)}
${avgFmt('ob_ratio', 'r', '', 2)}
${avgFmt('ob_ask', 'ask×', '', 1)}
<br>
${avgFmt('tp_pct', '익절', '%', 1)}
${avgFmt('tp_max_pct', '상한', '%', 1)}
${avgFmt('sl_pct', '손절', '%', 1)}
<br>
${stats['whip_subbar_sec'] ? `휩쏘ON ` : ''}
${avgFmt('whip_subbar_sec', 'sub', 's', 0)}
${avgFmt('whip_lookback_sec', 'lb', 's', 0)}
${avgFmt('whip_dip_pct', 'dip', '%', 2)}
${avgFmt('whip_recovery_tol_pct', 'tol', '%', 2)}
</div>
</div>`;
}
function optunaBuildGatedTop3Html(rows, opts) {'''
content = content.replace(target_build, repl_build)
target_build_inner = ''' let html = `<div class="mb-1 fw-semibold text-success">${phase} Top${n}${pr}${note}</div>`;
list.forEach((t, i) => {
html += optunaFormatGatedTopRowHtml(t, i + 1, i < list.length - 1);
});'''
repl_build_inner = ''' const stats = optunaCalcStats(list);
let html = `<div class="mb-1 fw-semibold text-success">${phase} Top${n}${pr}${note}</div>`;
html += optunaFormatAvgHtml(stats, list.length);
list.forEach((t, i) => {
html += optunaFormatGatedTopRowHtml(t, i + 1, i < list.length - 1, stats);
});'''
content = content.replace(target_build_inner, repl_build_inner)
# Fix dist chart
target_dist1 = ''' 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));'''
repl_dist1 = ''' const legendHtml = `<div class="mb-3 p-2" style="background:rgba(255,255,255,0.04);border-radius:6px;font-size:11px;line-height:1.7;">
<b style="font-size:12px;">📊 분포 차트 읽는 법</b><br>
<span style="display:inline-block;width:28px;height:8px;background:rgba(56,139,253,0.35);border-radius:3px;vertical-align:middle;margin-right:4px;"></span>파란 띠 = <b>p25 ~ p75</b> 구간 (상위합격 trial 중 중간 50% · IQR)<br>
<span style="display:inline-block;width:10px;height:10px;background:#3fb950;border-radius:2px;vertical-align:middle;margin-right:4px;"></span>녹색 선 = <b>median</b> (중앙값 · 합격 trial 중 딱 50번째 백분위)<br>
<span style="display:inline-block;width:10px;height:10px;background:#f0883e;border-radius:50%;vertical-align:middle;margin-right:4px;"></span>주황 점 = <b>mode</b> (최빈값 · DB 적용 후보에서 가장 많이 선택된 값)<br>
<span style="color:#aaa;">💡 주황(mode)과 녹색(median)이 가까울수록 분포가 안정적. 파란띠가 넓으면 파라미터 선택 폭이 큼. 녹색이 중앙에서 치우치는 건 분포가 한쪽으로 몰린 비대칭(skew) 때문.</span>
</div>`;
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);
const hi = Math.max(...vals);
const pad = (hi - lo) * 0.12 || Math.abs(lo) * 0.1 || 0.5;
const rangeMin = lo - pad;
const rangeMax = hi + pad;
const span = rangeMax - rangeMin || 1;
const pct = (v) => Math.max(0, Math.min(100, ((v - rangeMin) / span) * 100));'''
content = content.replace(target_dist1, repl_dist1)
target_dist2 = ''' let marks = '';
if (med != null && !Number.isNaN(med)) {
marks += `<div style="position:absolute;left:${pct(med).toFixed(1)}%;top:0;width:2px;height:100%;background:#3fb950" title="median ${med}"></div>`;
}
if (mode != null && !Number.isNaN(mode)) {
marks += `<div style="position:absolute;left:${pct(mode).toFixed(1)}%;top:3px;width:8px;height:8px;background:#f0883e;border-radius:50%" title="mode ${mode}"></div>`;
}
const l = pct(p25).toFixed(1);
const w = Math.max(1, pct(p75) - pct(p25)).toFixed(1);
return `<div class="mb-2">
<div class="d-flex justify-content-between align-items-center">
<code style="font-size:11px">${r.param}</code>
<span class="text-muted" style="font-size:10px">mode ${fmt(r.mode)} (${share})</span>
</div>
<div style="position:relative;height:14px;background:#21262d;border-radius:4px;margin:2px 0">
<div style="position:absolute;left:${l}%;width:${w}%;height:100%;background:rgba(56,139,253,0.4);border-radius:3px"></div>
${marks}
</div>
<div class="text-muted" style="font-size:10px">p25 ${fmt(p25)} · med ${fmt(med)} · p75 ${fmt(p75)}</div>
</div>`;
}).join('');
el.innerHTML = poolNote + bars;'''
repl_dist2 = ''' let marks = '';
if (med != null && !Number.isNaN(med)) {
marks += `<div style="position:absolute;left:calc(${pct(med).toFixed(1)}% - 1px);top:0;width:2px;height:100%;background:#3fb950;z-index:2;" title="median ${med}"></div>`;
marks += `<div style="position:absolute;left:${pct(med).toFixed(1)}%;top:-14px;transform:translateX(-50%);font-size:9px;color:#3fb950;white-space:nowrap;">${fmt(med)}</div>`;
}
if (mode != null && !Number.isNaN(mode)) {
marks += `<div style="position:absolute;left:calc(${pct(mode).toFixed(1)}% - 4px);top:3px;width:8px;height:8px;background:#f0883e;border-radius:50%;z-index:3;" title="mode ${mode}"></div>`;
}
const l = pct(p25).toFixed(1);
const w = Math.max(1, pct(p75) - pct(p25)).toFixed(1);
return `<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-1">
<code style="font-size:11px">${r.param}</code>
<span class="text-muted" style="font-size:10px">mode <b style="color:#f0883e">${fmt(r.mode)}</b> (${share}) · med <b style="color:#3fb950">${fmt(med)}</b> · p25~p75: ${fmt(p25)}~${fmt(p75)}</span>
</div>
<div style="position:relative;height:14px;background:#21262d;border-radius:4px;margin:16px 0 4px;">
<div style="position:absolute;left:${l}%;width:${w}%;height:100%;background:rgba(56,139,253,0.35);border-radius:3px;"></div>
${marks}
<div style="position:absolute;left:0;top:16px;font-size:9px;color:#555;white-space:nowrap;">${fmt(rangeMin)}</div>
<div style="position:absolute;right:0;top:16px;font-size:9px;color:#555;white-space:nowrap;">${fmt(rangeMax)}</div>
</div>
</div>`;
}).join('');
el.innerHTML = poolNote + legendHtml + bars;'''
content = content.replace(target_dist2, repl_dist2)
target_whip2 = 'function optunaObWhipCell(r) {'
repl_whip2 = '''function formatWhipParam(vRaw, prefix, suffix, dec, statKey, stats, scale100) {
if (vRaw == null) return null;
let v = Number(vRaw);
if (scale100 && v < 0.5) v *= 100;
let text = `${prefix}${v.toFixed(dec)}${suffix}`;
if (!stats || !stats[statKey]) return text;
const s = stats[statKey];
const dist = Math.abs(v - s.avg);
const ratio = s.maxDist > 0 ? 1 - (dist / s.maxDist) : 1;
const alpha = Math.round((0.2 + (0.8 * ratio)) * 100);
return `<span style="background: color-mix(in srgb, hotpink ${alpha}%, transparent); color:${alpha > 60 ? '#fff' : 'inherit'}; border-radius:3px; padding:0 3px;">${text}</span>`;
}
function optunaObWhipCell(r, stats) {'''
content = content.replace(target_whip2, repl_whip2)
target_whip3 = ''' 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(`<div class="text-pnl-pos" style="font-size:10px;line-height:1.25">${bits.join(' ')}</div>`);
} else if (on === false) {
lines.push('<div class="text-muted" style="font-size:10px;line-height:1.25">호가OFF</div>');
}
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(`<div class="text-muted" style="font-size:10px;line-height:1.25">${eb.join(' ')}</div>`);
}
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(`<div class="text-warning" style="font-size:10px;line-height:1.25">${wb.join(' ')}</div>`);
} else if (whip === false && (on != null || hasExit)) {
lines.push('<div class="text-muted" style="font-size:10px;line-height:1.25">휩쏘OFF</div>');
}'''
repl_whip3 = ''' 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'];
const spr = formatWhipParam(r.ob_spread, 'spr', '', 1, 'ob_spread', stats);
const ratio = formatWhipParam(r.ob_ratio, 'r', '', 2, 'ob_ratio', stats);
const ask = formatWhipParam(r.ob_ask, 'ask×', '', 0, 'ob_ask', stats);
if (spr) bits.push(spr);
if (ratio) bits.push(ratio);
if (ask) bits.push(ask);
lines.push(`<div class="text-pnl-pos" style="font-size:10px;line-height:1.4">${bits.join(' ')}</div>`);
} else if (on === false) {
lines.push('<div class="text-muted" style="font-size:10px;line-height:1.4">호가OFF</div>');
}
if (hasExit) {
const eb = [];
const tp = formatWhipParam(r.tp_pct, '익절', '%', 1, 'tp_pct', stats);
const tpMax = formatWhipParam(r.tp_max_pct, '상한', '%', 1, 'tp_max_pct', stats);
const sl = formatWhipParam(r.sl_pct, '손절', '%', 1, 'sl_pct', stats);
if (tp) eb.push(tp);
if (tpMax) eb.push(tpMax);
if (sl) eb.push(sl);
lines.push(`<div class="text-muted" style="font-size:10px;line-height:1.4">${eb.join(' ')}</div>`);
}
if (whip === true) {
const wb = ['휩쏘ON'];
const sub = formatWhipParam(r.whip_subbar_sec, 'sub', 's', 0, 'whip_subbar_sec', stats);
const lb = formatWhipParam(r.whip_lookback_sec, 'lb', 's', 0, 'whip_lookback_sec', stats);
const dip = formatWhipParam(r.whip_dip_pct, 'dip', '%', 2, 'whip_dip_pct', stats, true);
const tol = formatWhipParam(r.whip_recovery_tol_pct, 'tol', '%', 2, 'whip_recovery_tol_pct', stats, true);
if (sub) wb.push(sub);
if (lb) wb.push(lb);
if (dip) wb.push(dip);
if (tol) wb.push(tol);
lines.push(`<div class="text-warning" style="font-size:10px;line-height:1.4">${wb.join(' ')}</div>`);
} else if (whip === false && (on != null || hasExit)) {
lines.push('<div class="text-muted" style="font-size:10px;line-height:1.4">휩쏘OFF</div>');
}'''
content = content.replace(target_whip3, repl_whip3)
target_chk = ''' 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;'''
repl_chk = ''' const el = $(id);
if (!el || val === undefined || val === null) return;
if (el.type === 'checkbox') el.checked = !!(val === true || val === 1 || val === '1');
else {
el.style.cssText = '-webkit-appearance:checkbox!important;appearance:checkbox!important;width:15px!important;height:15px!important;min-width:15px;margin:0!important;padding:0!important;cursor:pointer;flex-shrink:0;accent-color:#58a6ff;background:none!important;border:none!important;box-shadow:none!important;float:none!important;';
el.value = val;
}'''
content = content.replace(target_chk, repl_chk)
target_jobs = '''async function optunaRefreshJobs() {
try {
const sort = $('opt_jobs_sort')?.value || 'started';'''
repl_jobs = '''let _optJobsCache = {}; // job_id -> job 객체 (list API 응답 캐시)
async function optunaRefreshJobs() {
try {
const sort = $('opt_jobs_sort')?.value || 'started';'''
content = content.replace(target_jobs, repl_jobs)
target_jobs2 = ''' tb.innerHTML = '<tr><td colspan="8" class="text-muted">잡 없음</td></tr>';
return;
}
tb.innerHTML = j.jobs.map(job => {
const prog = job.progress || {};
const jid = job.job_id;
const label = job.label || job.strategy || '';'''
repl_jobs2 = ''' tb.innerHTML = '<tr><td colspan="8" class="text-muted">잡 없음</td></tr>';
return;
}
const prevChks = document.querySelectorAll('.opt-cmp-chk:checked');
const checkedIds = new Set(Array.from(prevChks).map(c => c.value));
// 캐시 갱신
(j.jobs || []).forEach(job => { _optJobsCache[job.job_id] = job; });
tb.innerHTML = j.jobs.map(job => {
const prog = job.progress || {};
const jid = job.job_id;
const isChecked = checkedIds.has(jid) ? 'checked' : '';
const label = job.label || job.strategy || '';'''
content = content.replace(target_jobs2, repl_jobs2)
target_jobs3 = ''' ? `<button class="btn btn-sm btn-outline-primary py-0 ms-1" onclick="optunaContinue('${jid}')">이어 돌리기</button>` +
`<button class="btn btn-sm btn-outline-warning py-0 ms-1" onclick="optunaConfirmStudy('${jid}')">확정</button>`
: '';
return `<tr data-job-id="${jid}" style="${sel}">
<td><code style="font-size:10px">${jid}</code></td>
<td>${label}${studyNote}</td>'''
repl_jobs3 = ''' ? `<button class="btn btn-sm btn-outline-primary py-0 ms-1" onclick="optunaContinue('${jid}')">이어 돌리기</button>` +
`<button class="btn btn-sm btn-outline-warning py-0 ms-1" onclick="optunaConfirmStudy('${jid}')">확정</button>`
: '';
const runParams = [];
// 승리식: sort_by 필드 없으면 표시 생략 (구잡)
const sortByRaw = job.sort_by || null;
const sortByLabel = sortByRaw === 'score' ? '수익·낙폭' : sortByRaw === 'score_legacy' ? '구pnl/mdd' : sortByRaw === 'daily_avg' ? '일평균' : sortByRaw === 'pnl' ? '총손익' : sortByRaw;
if (sortByLabel) runParams.push('승리식:' + sortByLabel);
// 호가 ON/OFF: 백엔드에서 result_summary.tpe_includes_orderbook을 슬림화하여 내려줌
const obOn = job.tpe_includes_orderbook;
if (obOn === true) runParams.push('호가:ON');
if (obOn === false) runParams.push('호가:OFF');
if (job.mode && job.mode !== 'tpe') runParams.push('모드:' + job.mode);
if (job.symbol) runParams.push('종목:' + job.symbol);
if (job.universe_history_source) runParams.push('유니버스:' + job.universe_history_source);
if (job.tail_entry_modes) runParams.push('꼬리진입:' + job.tail_entry_modes);
if (job.breakout_sl_modes) runParams.push('돌파손절:' + job.breakout_sl_modes);
if (job.breakout_ob_modes) runParams.push('돌파호가:' + job.breakout_ob_modes);
const paramsDiv = runParams.length > 0 ? `<div style="font-size:10px; color:var(--muted); margin-top:2px; white-space: normal; line-height: 1.3;">${runParams.join(' · ')}</div>` : '';
return `<tr data-job-id="${jid}" style="${sel}">
<td style="padding:4px 6px;vertical-align:middle;">
<input class="form-check-input opt-cmp-chk" type="checkbox" value="${jid}" ${isChecked} style="margin-top:0; cursor:pointer;">
<code style="font-size:10px; cursor:pointer; margin-left:4px;" onclick="const cb=this.previousElementSibling; cb.checked=!cb.checked; cb.dispatchEvent(new Event('change'))">${jid}</code>
</td>
<td style="max-width:280px; white-space:normal;">${label}${studyNote}${paramsDiv}</td>'''
content = content.replace(target_jobs3, repl_jobs3)
target_cmp = '''optunaBoot();
kisMockBoot();
btRestoreTab();'''
repl_cmp = '''optunaBoot();
kisMockBoot();
btRestoreTab();
let optCmpModalInst = null;
async function compareSelectedJobs() {
const chks = document.querySelectorAll('.opt-cmp-chk:checked');
if (chks.length < 2) {
alert('\ube44\uad50\ud560 \uc78b\uc744 2\uac1c \uc774\uc0c1 \uc120\ud0dd\ud574\uc8fc\uc138\uc694.');
return;
}
const jobIds = Array.from(chks).map(c => c.value);
const modalEl = document.getElementById('optCmpModal');
if (!optCmpModalInst) {
optCmpModalInst = new bootstrap.Modal(modalEl);
}
document.getElementById('optCmpLoading').classList.remove('d-none');
document.getElementById('optCmpTableWrap').innerHTML = '';
optCmpModalInst.show();
try {
const jobData = [];
// 항상 topn API 호출 (리스트 API에는 top5_gated가 없기 때문)
for (const jid of jobIds) {
try {
const r = await fetch('/api/optuna/topn/' + encodeURIComponent(jid), {signal: AbortSignal.timeout(5000)});
const j2 = await r.json();
if (j2.ok) jobData.push(j2);
} catch(e) { console.warn('topn fetch failed for', jid, e); }
}
if (!jobData.length) {
document.getElementById('optCmpTableWrap').innerHTML = '<div class="text-warning py-3 text-center">\ubd88\ub7ec\uc628 \uc78b \ub370\uc774\ud130\uac00 \uc5c6\uc2b5\ub2c8\ub2e4.</div>';
return;
}
const pFmt = v => v != null ? Math.round(Number(v)).toLocaleString() : '\u2014';
const pRound2 = v => v != null ? Number(v).toFixed(2) : '\u2014';
const maxTop = 10;
// Header
let html = '<table class="table table-sm table-bordered" style="font-size:12px;">';
html += '<thead class="table-dark"><tr><th style="min-width:110px;">\uc9c0\ud45c</th>';
for (const d of jobData) {
const gated = d.top5_gated || (d.result_summary && d.result_summary.top5_gated) || [];
const stats = optunaCalcStats(gated);
d._stats = stats;
d._gatedList = gated;
const lbl = (d.label || d.strategy || d.job_id || '').replace(/<[^>]+>/g,'');
const sn = (d.study_short || '').replace(/<[^>]+>/g,'');
const sb = d.sort_by || 'score';
const sbTxt = sb==='score'?'\uc218\uc775\u00b7\ub099\ud3ed':sb==='score_legacy'?'\uad6cpnl/mdd':sb==='daily_avg'?'\uc77c\ud3c9\uade0':sb==='pnl'?'\ucd1d\uc190\uc775':sb;
const obTxt = d.tpe_includes_orderbook ? '호가:ON' : (d.tpe_includes_orderbook === false ? '호가:OFF' : '');
const jidShort = (d.job_id||'').replace(/^import_optuna_/, '');
html += `<th style="min-width:150px;white-space:normal;position:relative;vertical-align:top;">
<div style="font-weight:700;">${lbl}</div>
${sn?`<div style="font-size:10px;color:#aaa;">${sn}</div>`:''}
<div style="font-size:10px;color:#6ea8fe;">승리식: ${sbTxt} · ${d.mode||'tpe'}</div>
<div style="font-size:10px;color:#aaa;">${optunaFormatJobPeriodCell(d)}</div>
<div style="font-size:10px;color:#f0883e;margin-top:2px;">${obTxt}</div>
<div style="font-size:9px;color:#555;word-break:break-all;margin-top:2px;">${jidShort}</div>
<div style="margin-top:6px;font-weight:normal;">${optunaFormatAvgHtml(stats, gated.length)}</div>
</th>`;
}
html += '</tr></thead><tbody>';
const metricDefs = [
{ label:'\uc77c\ud3c9\uade0 \uc190\uc775(\uc6d0)', fn: g => {
const v = g.period_daily_avg_pnl ?? null;
if (v == null) return '\u2014';
const cls = v>0?'color:#3fb950':v<0?'color:#f85149':'';
return `<span style="${cls}">${(v>=0?'+':'') + pFmt(v)}</span>`;
}},
{ label:'\uc77c\ud3c9\uade0 \uc190\uc775(%)', fn: g => {
const v = g.period_daily_avg_pct ?? null;
if (v == null) return '\u2014';
const cls = v>0?'color:#3fb950':v<0?'color:#f85149':'';
return `<span style="${cls}">${(v>=0?'+':'')+Number(v).toFixed(3)}%</span>`;
}},
{ label:'\ucd1d \uc190\uc775(\uc6d0)', fn: g => {
const v = g.total_pnl ?? null;
if (v == null) return '\u2014';
const cls = v>0?'color:#3fb950':v<0?'color:#f85149':'';
return `<span style="${cls}">${pFmt(v)}</span>`;
}},
{ label:'\uc2b9\ub960 / PF', fn: g => `${pRound2(g.win_rate)}% / ${pRound2(g.pf)}` },
{ label:'\uac70\ub798\uc218 / trial', fn: g => `${g.total_trades??'\u2014'}\uac74 / #${g.optuna_trial_number??'\u2014'}` },
{ label:'\uc775\uc808 tp(%)', fn: g => {
const v = g.tp_pct ?? null;
return v!=null?`<span style="color:#79c0ff">${v}%</span>`:'\u2014';
}},
{ label:'\uc190\uc808 sl(%)', fn: g => {
const v = g.sl_pct ?? null;
return v!=null?`<span style="color:#f85149">${v}%</span>`:'\u2014';
}},
{ label:'\ud638\uac00 ON', fn: (g) => g.ob_on!=null?(g.ob_on?'\u2705':'\u274c'):'\u2014' },
{ label:'파라미터<br><span style="font-size:10px;color:#555;">(호가·익손절·휩쏘)</span>', fn: (g, d) => optunaObWhipCell(g, d._stats) },
];
for (let i = 0; i < maxTop; i++) {
html += `<tr style="background:rgba(255,255,255,0.06);">
<td colspan="${jobData.length+1}" style="font-weight:700;font-size:13px;color:#e3b341;padding:5px 8px;">
\ud83c\udfc6 Top ${i+1} \uc0ac\ud6c4\ud569\uaca9 \ud6c4\ubcf4
</td>
</tr>`;
for (const m of metricDefs) {
html += `<tr><td class="text-end" style="color:#8b949e;padding:2px 8px;">${m.label}</td>`;
for (const d of jobData) {
const g = (d._gatedList || [])[i];
html += g
? `<td class="${m.label.includes('파라미터') ? 'text-start' : 'text-end'}" style="padding:4px 6px; vertical-align:middle;">${m.fn(g, d)}</td>`
: `<td class="text-muted text-center" style="padding:2px 6px;">\u2014</td>`;
}
html += '</tr>';
}
}
html += '</tbody></table>';
document.getElementById('optCmpTableWrap').innerHTML = html;
} catch (err) {
document.getElementById('optCmpTableWrap').innerHTML =
`<div class="text-danger py-3 text-center">\uc624\ub958: ${err}</div>`;
} finally {
document.getElementById('optCmpLoading').classList.add('d-none');
}
}
'''
content = content.replace(target_cmp, repl_cmp)
with open(path, "w", encoding="utf-8") as f:
f.write(content)
print("backtest.js restored")