feat(Core/UI): 백테스트 전역 엔진 선택 버튼 추가 및 스캘핑 Rust 방어로직 포팅 1차 완료
This commit is contained in:
496
scratch/restore_backtest.py
Normal file
496
scratch/restore_backtest.py
Normal 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")
|
||||
Reference in New Issue
Block a user