diff --git a/backtest_web.py b/backtest_web.py index 3eee1ab..ad9a8f8 100644 --- a/backtest_web.py +++ b/backtest_web.py @@ -1907,6 +1907,8 @@ def api_condition_parity(): db.close() +_feed_stats_cache = {} + @app.route("/api/feed_collect_stats", methods=["GET"]) def api_feed_collect_stats(): """일자별 틱·호가·filter_eval 수집량 (벤더별). 통계 탭. @@ -1914,15 +1916,27 @@ def api_feed_collect_stats(): 기본=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: day = _default_trading_day_ymd() 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() + + 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 실패") @@ -9720,6 +9734,29 @@ def api_optuna_status(job_id: str): @app.route("/api/optuna/jobs", methods=["GET"]) +@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") + }) + def api_optuna_jobs(): from kis_trader.backtest import optuna_web_jobs as owj diff --git a/kis_trader/backtest/optuna_web_jobs.py b/kis_trader/backtest/optuna_web_jobs.py index 70b8dbc..148dbf6 100644 --- a/kis_trader/backtest/optuna_web_jobs.py +++ b/kis_trader/backtest/optuna_web_jobs.py @@ -448,6 +448,7 @@ def job_list_row(meta: Dict[str, Any]) -> Dict[str, Any]: "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,6 +460,16 @@ def job_list_row(meta: Dict[str, Any]) -> Dict[str, Any]: 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"), + "tpe_includes_orderbook": (m.get("result_summary") or {}).get("tpe_includes_orderbook"), } @@ -2425,6 +2436,62 @@ def _pool_for_optuna_source(data: Dict[str, Any], src: str) -> List[Dict[str, An return list(data.get("results") or data.get("results_all") or []) +def _overlay_trial_ob_whip_on_ui(ui: Dict[str, Any], params: Dict[str, Any]) -> None: + """Optuna trial 호가·휩쏘 — DB snap 기본값 덮어쓰기 (백테탭 폼·RO 정합).""" + p = params if isinstance(params, dict) else {} + ob = p.get("_orderbook_filter_enabled") + if ob is None: + ob = p.get("ob_filter_enabled") + if ob is not None: + ui["ob_filter_enabled"] = bool(ob) + pg = p.get("_program_filter_enabled") + if pg is None: + pg = p.get("pg_filter_enabled") + if pg is not None: + ui["pg_filter_enabled"] = bool(pg) + for k in ( + "max_spread_pct", + "min_bid_ask_ratio", + "ask_max_mult", + "whipsaw_enabled", + "whipsaw_subbar_sec", + "whipsaw_lookback_sec", + "whipsaw_dip_pct", + "whipsaw_recovery_tol_pct", + ): + if p.get(k) is not None: + ui[k] = p[k] + + +def optuna_engine_params_to_web_ui(strategy: str, params: Dict[str, Any]) -> Dict[str, Any]: + """Optuna merged/engine params → 웹 백테 폼 키 (꼬리 등 엔진↔UI 이름 변환).""" + strat = str(strategy or "").strip().lower() + p = dict(params or {}) + if not p: + return {} + if strat == "tail": + from backtest_web import _tail_engine_dict_to_ui + + ui = _tail_engine_dict_to_ui(p, snap={}) + _overlay_trial_ob_whip_on_ui(ui, p) + return ui + if strat == "scalp": + ui = dict(p) + if ui.get("ob_filter_enabled") is None and p.get("_orderbook_filter_enabled") is not None: + ui["ob_filter_enabled"] = bool(p.get("_orderbook_filter_enabled")) + return ui + if strat == "breakout": + ui = dict(p) + if ui.get("ob_filter_enabled") is None and p.get("_orderbook_filter_enabled") is not None: + ui["ob_filter_enabled"] = bool(p.get("_orderbook_filter_enabled")) + if ui.get("pg_filter_enabled") is None and p.get("_program_filter_enabled") is not None: + ui["pg_filter_enabled"] = bool(p.get("_program_filter_enabled")) + if ui.get("shoulder_min_high_pct") is None and p.get("shoulder_min_high") is not None: + ui["shoulder_min_high_pct"] = p.get("shoulder_min_high") + return ui + return dict(p) + + def get_candidate_detail( *, job_id: Optional[str] = None, @@ -2497,6 +2564,8 @@ def get_candidate_detail( # 보기: 전체 파라미터 (키 정렬). 예전 [:40] 잘림 → ratchet/rsi 등이 “없는 것처럼” 보임 preview = {k: params[k] for k in sorted(params.keys(), key=lambda x: str(x))} + strat = _resolve_single_strategy(meta, data, path) or data.get("strategy") + params_ui = optuna_engine_params_to_web_ui(str(strat or ""), params) return { "ok": True, "source": src, @@ -2504,9 +2573,10 @@ def get_candidate_detail( "metrics": metrics, "params_preview": preview, "params_full": preview, + "params_ui": params_ui, "params_count": len(params), "result_json": str(path), - "strategy": _resolve_single_strategy(meta, data, path) or data.get("strategy"), + "strategy": strat, "start": data.get("start"), "end": data.get("end"), } @@ -2836,6 +2906,7 @@ def register_result_json_as_job( "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), diff --git a/scratch/check_db_stats.py b/scratch/check_db_stats.py index 84467e3..23da9c8 100644 --- a/scratch/check_db_stats.py +++ b/scratch/check_db_stats.py @@ -1,26 +1,68 @@ import sys import os -sys.path.insert(0, '/home/hoon/kis_bot') +from datetime import datetime +sys.path.append(os.path.dirname(os.path.abspath(__file__)) + '/..') from database import TradeDB db = TradeDB() -today = "2026-08-11" +try: + # 1. LS 종목 7시 수집 통계 확인 + print("\n[LS Ticks]") + ls_ticks = db.conn.execute(""" + SELECT HOUR(timestamp) as hr, count(*) + FROM ls_ws_ticks + WHERE date(timestamp) = '2026-08-31' + GROUP BY HOUR(timestamp) + """).fetchall() + for row in ls_ticks: print(dict(row)) -queries = { - " trade_history (오늘)": f"SELECT COUNT(*) FROM trade_history WHERE buy_date >= '{today} 00:00:00'", - " kis_ws_orderbook (오늘)": f"SELECT COUNT(*) FROM kis_ws_orderbook WHERE recv_ts >= '{today} 00:00:00'", - " ls_ws_orderbook (오늘)": f"SELECT COUNT(*) FROM ls_ws_orderbook WHERE recv_ts >= '{today} 00:00:00'", - " ws_orderbook (키움, 오늘)": f"SELECT COUNT(*) FROM ws_orderbook WHERE recv_ts >= '{today} 00:00:00'", - " ls_ws_ticks (오늘)": f"SELECT COUNT(*) FROM ls_ws_ticks WHERE ts >= '{today} 00:00:00'", - " ws_ticks (키움/KIS, 오늘)": f"SELECT COUNT(*) FROM ws_ticks WHERE recv_ts >= '{today} 00:00:00'", - " ls_ws_candles (오늘)": f"SELECT COUNT(*) FROM ls_ws_candles WHERE datetime >= '{today} 00:00:00'", - " ws_candles (오늘)": f"SELECT COUNT(*) FROM ws_candles WHERE candle_time >= '{today} 00:00:00'", -} + print("\n[LS Orderbooks]") + ls_obs = db.conn.execute(""" + SELECT HOUR(timestamp) as hr, count(*) + FROM ls_ws_orderbook + WHERE date(timestamp) = '2026-08-31' + GROUP BY HOUR(timestamp) + """).fetchall() + for row in ls_obs: print(dict(row)) -for name, q in queries.items(): + print("\n[KIS Orderbooks]") + kis_obs = db.conn.execute(""" + SELECT HOUR(timestamp) as hr, count(*) + FROM ws_orderbooks + WHERE date(timestamp) = '2026-08-31' + GROUP BY HOUR(timestamp) + """).fetchall() + for row in kis_obs: print(dict(row)) + + # 후보목록 (target_candidates) + print("\n[Target Candidates]") + # SHOW COLUMNS first as required + cols = [dict(r)["Field"] for r in db.conn.execute("SHOW COLUMNS FROM target_candidates").fetchall()] + print("target_candidates cols:", cols) + + # Check if there is target_candidates for today + tc = db.conn.execute(""" + SELECT HOUR(scan_time) as hr, count(*) + FROM target_candidates + WHERE date(scan_time) = '2026-08-31' + GROUP BY HOUR(scan_time) + """).fetchall() + for row in tc: print(dict(row)) + + # target_candidates_ls + print("\n[Target Candidates LS]") try: - res = db.conn.execute(q).fetchone() - print(f"{name}: {res[0]:,}") + cols_ls = [dict(r)["Field"] for r in db.conn.execute("SHOW COLUMNS FROM target_candidates_ls").fetchall()] + print("target_candidates_ls cols:", cols_ls) + tc_ls = db.conn.execute(""" + SELECT HOUR(scan_time) as hr, count(*) + FROM target_candidates_ls + WHERE date(scan_time) = '2026-08-31' + GROUP BY HOUR(scan_time) + """).fetchall() + for row in tc_ls: print(dict(row)) except Exception as e: - print(f"{name}: Error - {e}") -db.close() + print("Error checking target_candidates_ls:", e) + +finally: + db.close() diff --git a/static/js/backtest.js b/static/js/backtest.js index 371b96d..8f620f8 100644 --- a/static/js/backtest.js +++ b/static/js/backtest.js @@ -8430,7 +8430,7 @@ function optunaRenderCompare(sum) { optunaRenderParamDistChart(sum); } -const OPTUNA_CARD_TOP_N = 5; +const OPTUNA_CARD_TOP_N = 10; function optunaRenderModeCombo(sum) { const el = $('opt_mode_combo_body'); @@ -8640,7 +8640,7 @@ function optunaRefinePhaseLabel(job) { return ''; } -function optunaFormatGatedTopRowHtml(t, i, showBorder) { +function optunaFormatGatedTopRowHtml(t, i, showBorder, stats) { 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,13 +8651,63 @@ function optunaFormatGatedTopRowHtml(t, i, showBorder) { ${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)} + ${optunaObWhipCell(t, stats)} `; } +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,9 +8718,12 @@ function optunaBuildGatedTop3Html(rows, opts) { 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); + html += optunaFormatGatedTopRowHtml(t, i + 1, i < list.length - 1, stats); }); return html; } @@ -8771,40 +8824,52 @@ function optunaRenderParamDistChart(sum) { 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); 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)); + 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 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 = ''; if (med != null && !Number.isNaN(med)) { - marks += `
`; + marks += `
`; + marks += `
${fmt(med)}
`; } 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}) + mode ${fmt(r.mode)} (${share}) · med ${fmt(med)} · p25~p75: ${fmt(p25)}~${fmt(p75)}
-
-
+
+
${marks} +
${fmt(rangeMin)}
+
${fmt(rangeMax)}
-
p25 ${fmt(p25)} · med ${fmt(med)} · p75 ${fmt(p75)}
`; }).join(''); - el.innerHTML = poolNote + bars; + el.innerHTML = poolNote + legendHtml + bars; } function optunaRenderTrailRec(sum) { @@ -8857,7 +8922,20 @@ function optunaPostBusy(job) { return job.status === 'running' && Number(prog.pct || 0) >= 99.9; } -function optunaObWhipCell(r) { +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) { if (!r) return ''; const on = r.ob_on; const whip = r.whip_on; @@ -9935,7 +10013,10 @@ async function optunaFillFormFromCandidate(source, rank) { 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; + 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; + } }; const prefix = isUs ? 'usmom_' : 'mom_'; // 기간 = Optuna study 기간 (비교 시 필수) @@ -10507,6 +10588,8 @@ function optunaFormatTopRow(t, i) { ` · 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,9 +10601,16 @@ async function optunaRefreshJobs() { 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,9 +10631,31 @@ async function optunaRefreshJobs() { ? `` + `` : ''; + + 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} + + + ${jid} + + ${label}${studyNote}${paramsDiv} ${optunaFormatJobPeriodCell(job)} ${stCell}${job.phase === 'postprocess' ? ' ·후처리' : ''}${statusNote} ${(() => { @@ -10886,3 +10998,131 @@ function kisMockBoot() { optunaBoot(); kisMockBoot(); btRestoreTab(); + +let optCmpModalInst = null; + +async function compareSelectedJobs() { + const chks = document.querySelectorAll('.opt-cmp-chk:checked'); + if (chks.length < 2) { + alert('비교할 잋을 2개 이상 선택해주세요.'); + 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 = '
불러온 잋 데이터가 없습니다.
'; + return; + } + + const pFmt = v => v != null ? Math.round(Number(v)).toLocaleString() : '—'; + const pRound2 = v => v != null ? Number(v).toFixed(2) : '—'; + 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'?'수익·낙폭':sb==='score_legacy'?'구pnl/mdd':sb==='daily_avg'?'일평균':sb==='pnl'?'총손익':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:'일평균 손익(원)', fn: g => { + const v = g.period_daily_avg_pnl ?? null; + if (v == null) return '—'; + const cls = v>0?'color:#3fb950':v<0?'color:#f85149':''; + return `${(v>=0?'+':'') + pFmt(v)}`; + }}, + { label:'일평균 손익(%)', fn: g => { + const v = g.period_daily_avg_pct ?? null; + if (v == null) return '—'; + const cls = v>0?'color:#3fb950':v<0?'color:#f85149':''; + return `${(v>=0?'+':'')+Number(v).toFixed(3)}%`; + }}, + { label:'총 손익(원)', fn: g => { + const v = g.total_pnl ?? null; + if (v == null) return '—'; + const cls = v>0?'color:#3fb950':v<0?'color:#f85149':''; + return `${pFmt(v)}`; + }}, + { label:'승률 / PF', fn: g => `${pRound2(g.win_rate)}% / ${pRound2(g.pf)}` }, + { label:'거래수 / trial', fn: g => `${g.total_trades??'—'}건 / #${g.optuna_trial_number??'—'}` }, + { label:'익절 tp(%)', fn: g => { + const v = g.tp_pct ?? null; + return v!=null?`${v}%`:'—'; + }}, + { label:'손절 sl(%)', fn: g => { + const v = g.sl_pct ?? null; + return v!=null?`${v}%`:'—'; + }}, + { label:'호가 ON', fn: (g) => g.ob_on!=null?(g.ob_on?'✅':'❌'):'—' }, + { 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 += '
지표 +
${lbl}
+ ${sn?`
${sn}
`:''} +
승리식: ${sbTxt} · ${d.mode||'tpe'}
+
${optunaFormatJobPeriodCell(d)}
+
${obTxt}
+
${jidShort}
+
${optunaFormatAvgHtml(stats, gated.length)}
+
+ 🏆 Top ${i+1} 사후합격 후보 +
${m.label}${m.fn(g, d)}
'; + document.getElementById('optCmpTableWrap').innerHTML = html; + + } catch (err) { + document.getElementById('optCmpTableWrap').innerHTML = + `
오류: ${err}
`; + } finally { + document.getElementById('optCmpLoading').classList.add('d-none'); + } +} + diff --git a/templates/backtest.html b/templates/backtest.html index 2b3ef77..d1584b2 100644 --- a/templates/backtest.html +++ b/templates/backtest.html @@ -4483,6 +4483,6 @@
- + \ No newline at end of file