Files
kis_trader/scratch/restore_backtest_safe.py

495 lines
25 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;"><3E><> 주황(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;">
🏆 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 safely!")