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 = ' ${optunaObWhipCell(t)}' repl_whip = ' ${optunaObWhipCell(t, stats)}' 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 `
수치 평균 (Top${len} 기준 · 근접시 진하게)
${stats['ob_spread'] ? `호가ON ` : ''} ${avgFmt('ob_spread', 'spr', '', 1)} ${avgFmt('ob_ratio', 'r', '', 2)} ${avgFmt('ob_ask', 'ask×', '', 1)}
${avgFmt('tp_pct', '익절', '%', 1)} ${avgFmt('tp_max_pct', '상한', '%', 1)} ${avgFmt('sl_pct', '손절', '%', 1)}
${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)}
`; } function optunaBuildGatedTop3Html(rows, opts) {''' content = content.replace(target_build, repl_build) target_build_inner = ''' let html = `
${phase} Top${n}${pr}${note}
`; list.forEach((t, i) => { html += optunaFormatGatedTopRowHtml(t, i + 1, i < list.length - 1); });''' repl_build_inner = ''' const stats = optunaCalcStats(list); let html = `
${phase} Top${n}${pr}${note}
`; 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 = `
📊 분포 차트 읽는 법
파란 띠 = p25 ~ p75 구간 (상위합격 trial 중 중간 50% · IQR)
녹색 선 = median (중앙값 · 합격 trial 중 딱 50번째 백분위)
주황 점 = mode (최빈값 · DB 적용 후보에서 가장 많이 선택된 값)
💡 주황(mode)과 녹색(median)이 가까울수록 분포가 안정적. 파란띠가 넓으면 파라미터 선택 폭이 큼. 녹색이 중앙에서 치우치는 건 분포가 한쪽으로 몰린 비대칭(skew) 때문.
`; 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 += `
`; } 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})
${marks}
p25 ${fmt(p25)} · med ${fmt(med)} · p75 ${fmt(p75)}
`; }).join(''); el.innerHTML = poolNote + bars;''' repl_dist2 = ''' let marks = ''; if (med != null && !Number.isNaN(med)) { marks += `
`; marks += `
${fmt(med)}
`; } 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}) · med ${fmt(med)} · p25~p75: ${fmt(p25)}~${fmt(p75)}
${marks}
${fmt(rangeMin)}
${fmt(rangeMax)}
`; }).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 `${text}`; } 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(`
${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
'); }''' 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(`
${bits.join(' ')}
`); } else if (on === false) { lines.push('
호가OFF
'); } 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(`
${eb.join(' ')}
`); } 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(`
${wb.join(' ')}
`); } else if (whip === false && (on != null || hasExit)) { lines.push('
휩쏘OFF
'); }''' 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 = '잡 없음'; 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 = '잡 없음'; 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 = ''' ? `` + `` : ''; return ` ${jid} ${label}${studyNote}''' repl_jobs3 = ''' ? `` + `` : ''; 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 ? `
${runParams.join(' · ')}
` : ''; return ` ${jid} ${label}${studyNote}${paramsDiv}''' 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 = '
\ubd88\ub7ec\uc628 \uc78b \ub370\uc774\ud130\uac00 \uc5c6\uc2b5\ub2c8\ub2e4.
'; 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 = ''; html += ''; 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 += ``; } html += ''; 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 `${(v>=0?'+':'') + pFmt(v)}`; }}, { 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 `${(v>=0?'+':'')+Number(v).toFixed(3)}%`; }}, { 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 `${pFmt(v)}`; }}, { 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?`${v}%`:'\u2014'; }}, { label:'\uc190\uc808 sl(%)', fn: g => { const v = g.sl_pct ?? null; return v!=null?`${v}%`:'\u2014'; }}, { label:'\ud638\uac00 ON', fn: (g) => g.ob_on!=null?(g.ob_on?'\u2705':'\u274c'):'\u2014' }, { label:'파라미터
(호가·익손절·휩쏘)', fn: (g, d) => optunaObWhipCell(g, d._stats) }, ]; for (let i = 0; i < maxTop; i++) { html += ``; for (const m of metricDefs) { html += ``; for (const d of jobData) { const g = (d._gatedList || [])[i]; html += g ? `` : ``; } html += ''; } } html += '
\uc9c0\ud45c
${lbl}
${sn?`
${sn}
`:''}
승리식: ${sbTxt} · ${d.mode||'tpe'}
${optunaFormatJobPeriodCell(d)}
${obTxt}
${jidShort}
${optunaFormatAvgHtml(stats, gated.length)}
\ud83c\udfc6 Top ${i+1} \uc0ac\ud6c4\ud569\uaca9 \ud6c4\ubcf4
${m.label}${m.fn(g, d)}\u2014
'; document.getElementById('optCmpTableWrap').innerHTML = html; } catch (err) { document.getElementById('optCmpTableWrap').innerHTML = `
\uc624\ub958: ${err}
`; } 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")