580 lines
29 KiB
Diff
580 lines
29 KiB
Diff
--- a/kis_trader/backtest/optuna_web_jobs.py
|
||
+++ b/kis_trader/backtest/optuna_web_jobs.py
|
||
@@ -448,7 +448,6 @@
|
||
"join_cmd_ps": m.get("join_cmd_ps") or "",
|
||
"web_cmd": m.get("web_cmd") or m.get("cmd") or "",
|
||
"web_cmd_full": m.get("web_cmd_full") or "",
|
||
- "mode": m.get("mode"),
|
||
"join_study": m.get("join_study") or m.get("active_study_name") or m.get("study_name") or "",
|
||
"join_hint": m.get("join_hint") or "",
|
||
"join_cmds_all": m.get("join_cmds_all") or [],
|
||
@@ -459,17 +459,6 @@
|
||
str(m.get("study_name") or m.get("active_study_name") or "")
|
||
),
|
||
"source": m.get("source"),
|
||
- "sort_by": m.get("sort_by"),
|
||
- "symbol": m.get("symbol"),
|
||
- "universe_history_source": m.get("universe_history_source"),
|
||
- "candle_source": m.get("candle_source"),
|
||
- "tick_source": m.get("tick_source"),
|
||
- "ob_source": m.get("ob_source"),
|
||
- "tail_entry_modes": m.get("tail_entry_modes"),
|
||
- "breakout_sl_modes": m.get("breakout_sl_modes"),
|
||
- "breakout_ob_modes": m.get("breakout_ob_modes"),
|
||
- # result_summary에서 추출한 ob 축 포함 여부 (sort_by가 없는 구잡 대응)
|
||
- "tpe_includes_orderbook": (m.get("result_summary") or {}).get("tpe_includes_orderbook"),
|
||
}
|
||
|
||
|
||
@@ -2836,7 +2836,6 @@
|
||
"label": label,
|
||
"strategy": strat,
|
||
"mode": mode,
|
||
- "sort_by": data.get("sort_by") or meta.get("sort_by"),
|
||
"start": start,
|
||
"end": end,
|
||
"trials": int(data.get("optuna_trials_completed") or data.get("optuna_n_trials_requested") or 0),
|
||
--- a/static/js/backtest.js
|
||
+++ b/static/js/backtest.js
|
||
@@ -8430,7 +8430,7 @@
|
||
optunaRenderParamDistChart(sum);
|
||
}
|
||
|
||
-const OPTUNA_CARD_TOP_N = 10;
|
||
+const OPTUNA_CARD_TOP_N = 5;
|
||
|
||
function optunaRenderModeCombo(sum) {
|
||
const el = $('opt_mode_combo_body');
|
||
@@ -8640,7 +8640,7 @@
|
||
return '';
|
||
}
|
||
|
||
-function optunaFormatGatedTopRowHtml(t, i, showBorder, stats) {
|
||
+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;
|
||
@@ -8651,7 +8651,7 @@
|
||
${dAvg != null ? `<span>일평균 ${Number(dAvg).toLocaleString()}원</span>` : ''}
|
||
${dPct != null ? `<span>일평균 ${Number(dPct) >= 0 ? '+' : ''}${Number(dPct).toFixed(3)}%</span>` : ''}
|
||
<span>WR ${Number(t.win_rate || 0).toFixed(1)}% · PF ${Number(t.pf || 0).toFixed(2)} · ${Number(t.total_trades || 0)}건</span>
|
||
- <span>${optunaObWhipCell(t, stats)}</span>
|
||
+ <span>${optunaObWhipCell(t)}</span>
|
||
<button type="button" class="btn btn-sm btn-outline-secondary py-0" onclick="optunaShowCandidate('gated',${rank})">보기</button>
|
||
<button type="button" class="btn btn-sm btn-outline-primary py-0" onclick="optunaBacktestFromCandidate('gated',${rank})">백테</button>
|
||
<button type="button" class="btn btn-sm btn-outline-success py-0" onclick="optunaApply('gated',${rank})">적용</button>
|
||
@@ -8658,55 +8658,5 @@
|
||
}
|
||
|
||
-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) {
|
||
const o = opts || {};
|
||
const n = o.topN || OPTUNA_CARD_TOP_N;
|
||
@@ -8668,13 +8668,9 @@
|
||
const note = o.running
|
||
? ' <span class="text-warning">(진행 중 · 사후합격 미확정)</span>'
|
||
: '';
|
||
-
|
||
- 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);
|
||
+ list.forEach((t, i) => {
|
||
+ html += optunaFormatGatedTopRowHtml(t, i + 1, i < list.length - 1);
|
||
});
|
||
return html;
|
||
}
|
||
@@ -8771,13 +8771,6 @@
|
||
const poolNote = d.threshold_pool
|
||
? `<div class="text-muted mb-2" style="font-size:11px">표본: ${d.threshold_pool} · n=${d.threshold_pool_n || 0}</div>`
|
||
: '';
|
||
- 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);
|
||
@@ -8784,12 +8784,9 @@
|
||
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));
|
||
+ 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 = '';
|
||
@@ -8796,25 +8796,23 @@
|
||
- 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>`;
|
||
+ 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:calc(${pct(mode).toFixed(1)}% - 4px);top:3px;width:8px;height:8px;background:#f0883e;border-radius:50%;z-index:3;" title="mode ${mode}"></div>`;
|
||
+ 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-3">
|
||
- <div class="d-flex justify-content-between align-items-center mb-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 <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>
|
||
+ <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 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;
|
||
+ </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;
|
||
}
|
||
|
||
function optunaRenderTrailRec(sum) {
|
||
@@ -8857,20 +8857,7 @@
|
||
return job.status === 'running' && Number(prog.pct || 0) >= 99.9;
|
||
}
|
||
|
||
-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) {
|
||
+function optunaObWhipCell(r) {
|
||
if (!r) return '<span class="text-muted">—</span>';
|
||
const on = r.ob_on;
|
||
const whip = r.whip_on;
|
||
@@ -8877,6 +8877,9 @@
|
||
if (on == null && whip == null && !hasExit && !r.ob_summary) {
|
||
return '<span class="text-muted" title="구JSON 또는 호가·익절·손절·휩쏘 축 없음">—</span>';
|
||
}
|
||
+ 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 호가·익절·손절·휩쏘');
|
||
@@ -8883,15 +8883,12 @@
|
||
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>`);
|
||
+ 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.4">호가OFF</div>');
|
||
+ lines.push('<div class="text-muted" style="font-size:10px;line-height:1.25">호가OFF</div>');
|
||
}
|
||
|
||
if (hasExit) {
|
||
@@ -8898,10 +8898,7 @@
|
||
- 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 (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) {
|
||
@@ -8908,14 +8908,18 @@
|
||
- 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>`);
|
||
+ 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.4">휩쏘OFF</div>');
|
||
+ lines.push('<div class="text-muted" style="font-size:10px;line-height:1.25">휩쏘OFF</div>');
|
||
}
|
||
|
||
if (!lines.length) {
|
||
@@ -9935,10 +9935,7 @@
|
||
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;
|
||
- }
|
||
+ else el.value = val;
|
||
};
|
||
const prefix = isUs ? 'usmom_' : 'mom_';
|
||
// 기간 = Optuna study 기간 (비교 시 필수)
|
||
@@ -10507,8 +10507,6 @@
|
||
` · WR ${Number(t.win_rate || 0).toFixed(1)}% · PF ${Number(t.pf || 0).toFixed(2)} · score ${Number(t.score || 0).toFixed(3)}`;
|
||
}
|
||
|
||
-let _optJobsCache = {}; // job_id -> job 객체 (list API 응답 캐시)
|
||
-
|
||
async function optunaRefreshJobs() {
|
||
try {
|
||
const sort = $('opt_jobs_sort')?.value || 'started';
|
||
@@ -10518,16 +10518,9 @@
|
||
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 || '';
|
||
const studyNote = (job.study_short && job.kind === 'import')
|
||
? `<br><span class="text-muted" style="font-size:10px" title="${job.study_name || ''}">${job.study_short}</span>`
|
||
@@ -10541,31 +10541,9 @@
|
||
? `<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>
|
||
+ <td><code style="font-size:10px">${jid}</code></td>
|
||
+ <td>${label}${studyNote}</td>
|
||
<td>${optunaFormatJobPeriodCell(job)}</td>
|
||
<td>${stCell}${job.phase === 'postprocess' ? ' ·후처리' : ''}${statusNote}</td>
|
||
<td>${(() => {
|
||
@@ -10886,131 +10886,4 @@
|
||
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');
|
||
- }
|
||
-}
|
||
|
||
--- a/backtest_web.py
|
||
+++ b/backtest_web.py
|
||
@@ -1907,8 +1907,6 @@
|
||
db.close()
|
||
|
||
|
||
-_feed_stats_cache = {}
|
||
-
|
||
@app.route("/api/feed_collect_stats", methods=["GET"])
|
||
def api_feed_collect_stats():
|
||
"""일자별 틱·호가·filter_eval 수집량 (벤더별). 통계 탭.
|
||
@@ -1915,7 +1915,6 @@
|
||
기본=COUNT + 폴백나이. heavy=1 이면 종목교집합·filter_eval 합격/거부 추가. 최근 N일 일괄집계 없음.
|
||
"""
|
||
from kis_trader.web.feed_collect_stats import build_feed_collect_stats
|
||
- import time
|
||
|
||
day = (request.args.get("date") or request.args.get("day") or "").strip()[:10]
|
||
if not day:
|
||
@@ -1922,20 +1922,8 @@
|
||
heavy_raw = (request.args.get("heavy") or request.args.get("detail") or "0").strip().lower()
|
||
heavy = heavy_raw in ("1", "true", "yes", "y", "on")
|
||
-
|
||
- cache_key = f"{day}_{heavy}"
|
||
- now = time.time()
|
||
-
|
||
- # 캐시 확인 (오늘 데이터는 60초 갱신, 과거 데이터는 영구 캐시)
|
||
- if cache_key in _feed_stats_cache:
|
||
- cached = _feed_stats_cache[cache_key]
|
||
- is_today = (day.replace("-", "") == _default_trading_day_ymd().replace("-", ""))
|
||
- if not is_today or (now - cached["ts"] < 60):
|
||
- return jsonify({"ok": True, **cached["data"]})
|
||
-
|
||
db = _db()
|
||
try:
|
||
payload = build_feed_collect_stats(db, day=day, heavy=heavy)
|
||
- _feed_stats_cache[cache_key] = {"ts": now, "data": payload}
|
||
return jsonify({"ok": True, **payload})
|
||
except Exception as e:
|
||
logger.exception("feed_collect_stats 실패")
|
||
@@ -9719,30 +9719,6 @@
|
||
return jsonify({"ok": True, "job": owj.refresh_job_status(meta)})
|
||
|
||
|
||
-@app.route("/api/optuna/topn/<job_id>", methods=["GET"])
|
||
-def api_optuna_topn(job_id: str):
|
||
- from kis_trader.backtest import optuna_web_jobs as owj
|
||
- meta = owj.load_job(job_id)
|
||
- if not meta:
|
||
- return jsonify({"ok": False, "error": "없는 job_id"}), 404
|
||
- rs = meta.get("result_summary") or {}
|
||
- return jsonify({
|
||
- "ok": True,
|
||
- "job_id": job_id,
|
||
- "label": meta.get("label") or meta.get("strategy") or job_id,
|
||
- "strategy": meta.get("strategy"),
|
||
- "sort_by": meta.get("sort_by"),
|
||
- "mode": meta.get("mode"),
|
||
- "start": meta.get("start"),
|
||
- "end": meta.get("end"),
|
||
- "study_short": meta.get("study_short"),
|
||
- "tpe_includes_orderbook": rs.get("tpe_includes_orderbook"),
|
||
- "period_info": meta.get("period_info"),
|
||
- "top5_gated": rs.get("top5_gated") or [],
|
||
- "n_trading_days": rs.get("n_trading_days")
|
||
- })
|
||
-
|
||
-
|
||
@app.route("/api/optuna/jobs", methods=["GET"])
|
||
def api_optuna_jobs():
|
||
from kis_trader.backtest import optuna_web_jobs as owj
|