--- 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 ? `일평균 ${Number(dAvg).toLocaleString()}원` : ''} ${dPct != null ? `일평균 ${Number(dPct) >= 0 ? '+' : ''}${Number(dPct).toFixed(3)}%` : ''} WR ${Number(t.win_rate || 0).toFixed(1)}% · PF ${Number(t.pf || 0).toFixed(2)} · ${Number(t.total_trades || 0)}건 - ${optunaObWhipCell(t, stats)} + ${optunaObWhipCell(t)} @@ -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 `
-
수치 평균 (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) { const o = opts || {}; const n = o.topN || OPTUNA_CARD_TOP_N; @@ -8668,13 +8668,9 @@ const note = o.running ? ' (진행 중 · 사후합격 미확정)' : ''; - - 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); + list.forEach((t, i) => { + html += optunaFormatGatedTopRowHtml(t, i + 1, i < list.length - 1); }); return html; } @@ -8771,13 +8771,6 @@ const poolNote = d.threshold_pool ? `
표본: ${d.threshold_pool} · n=${d.threshold_pool_n || 0}
` : ''; - 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); @@ -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 += `
`; - marks += `
${fmt(med)}
`; + marks += `
`; } if (mode != null && !Number.isNaN(mode)) { - marks += `
`; + marks += `
`; } const l = pct(p25).toFixed(1); const w = Math.max(1, pct(p75) - pct(p25)).toFixed(1); - return `
-
+ return `
+
${r.param} - mode ${fmt(r.mode)} (${share}) · med ${fmt(med)} · p25~p75: ${fmt(p25)}~${fmt(p75)} -
-
-
+ mode ${fmt(r.mode)} (${share}) +
+
+
${marks} -
${fmt(rangeMin)}
-
${fmt(rangeMax)}
-
-
`; - }).join(''); - el.innerHTML = poolNote + legendHtml + bars; +
+
p25 ${fmt(p25)} · med ${fmt(med)} · p75 ${fmt(p75)}
+
`; + }).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 `${text}`; -} - -function optunaObWhipCell(r, stats) { +function optunaObWhipCell(r) { if (!r) return ''; const on = r.ob_on; const whip = r.whip_on; @@ -8877,6 +8877,9 @@ if (on == null && whip == null && !hasExit && !r.ob_summary) { return ''; } + 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(`
${bits.join(' ')}
`); + 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
'); + lines.push('
호가OFF
'); } 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(`
${eb.join(' ')}
`); + 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) { @@ -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(`
${wb.join(' ')}
`); + 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
'); + lines.push('
휩쏘OFF
'); } 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 = '잡 없음'; 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') ? `
${job.study_short}` @@ -10541,31 +10541,9 @@ ? `` + `` : ''; - - 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} + ${jid} + ${label}${studyNote} ${optunaFormatJobPeriodCell(job)} ${stCell}${job.phase === 'postprocess' ? ' ·후처리' : ''}${statusNote} ${(() => { @@ -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 = '
\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'); - } -} --- 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/", 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