diff --git a/backtest_web.py b/backtest_web.py index fc5df1d..07f7395 100644 --- a/backtest_web.py +++ b/backtest_web.py @@ -9931,16 +9931,129 @@ def api_optuna_status(job_id: str): meta["period_info"] = owj._build_period_info(meta) except Exception: pass - + + # 2026-09-06 (룰 19): 옛 잡 확장 파라미터 auto-inject (status/topn 공통) + _inject_extended_params_fallback(meta) + return jsonify({"ok": True, "job": meta}) +def _inject_extended_params_fallback(meta): + """2026-09-06 (룰 19 사용자 요청): 옛 잡의 result_summary 재파싱 fallback. + + - top5_gated 각 row 에 확장 파라미터(entry_*, rsi_*, shoulder_*) 뿌리기 + - overfit_diagnostics 를 Y안(팩터1 40→25, 팩터5 재해석, 팩터6 신설)으로 재계산 + + result_summary 저장 시점의 옛 코드는 이 두 개가 모두 옛 로직임. + """ + from kis_trader.backtest import optuna_web_jobs as owj + from kis_trader.backtest.optuna_common import ( + build_optuna_overfit_diagnostics, overfit_risk_pct_for_row, + ) + from pathlib import Path + import json as _json + + if not isinstance(meta, dict): + return + rs = meta.get("result_summary") if isinstance(meta.get("result_summary"), dict) else None + if not rs: + return + top5g = rs.get("top5_gated") or [] + top5s = rs.get("top5_stable") or [] + result_json = str(meta.get("result_json") or "") + if not result_json or not Path(result_json).is_file(): + return + + # 확장 파라미터 필요 여부 + needs_params = top5g and any( + (not isinstance(row, dict)) or row.get("entry_drop_rate") is None for row in top5g + ) + # overfit_diagnostics — 팩터 6 없으면 옛 값 → 재계산 필요 + old_diag = rs.get("overfit_diagnostics") if isinstance(rs.get("overfit_diagnostics"), dict) else None + old_factor_ids = [f.get("id") for f in (old_diag.get("factors") or [])] if old_diag else [] + needs_diag = old_diag is None or "param_stability" not in old_factor_ids + + if not (needs_params or needs_diag): + return + + try: + with open(result_json, encoding="utf-8") as f: + d = _json.load(f) + except Exception: + return + + # 1) 확장 파라미터 (top5_gated 재파싱) + if needs_params: + src_pool = d.get("results_gated") or d.get("results") or [] + by_trial = {} + for r in src_pool: + tn = r.get("optuna_trial_number") + if tn is not None: + by_trial[tn] = r + for row in top5g: + if not isinstance(row, dict): + continue + tn = row.get("optuna_trial_number") + src = by_trial.get(tn) + if isinstance(src, dict): + params = src.get("merged_params") or src.get("params") or {} + if isinstance(params, dict): + try: + extra = owj._ob_whip_ui_from_params(params) + for k, v in extra.items(): + if k not in row or row.get(k) is None: + row[k] = v + except Exception: + pass + + # 2) overfit_diagnostics Y안 재계산 + if needs_diag: + try: + new_diag = build_optuna_overfit_diagnostics(d) + if isinstance(new_diag, dict) and new_diag.get("overfit_risk_pct") is not None: + rs["overfit_diagnostics"] = new_diag + # 옛 캐시된 apply_overfit_* 필드도 동기화 (프론트가 이걸 우선 볼 수 있음) + rs["apply_overfit_pct"] = new_diag.get("overfit_risk_pct") + rs["apply_overfit_verdict"] = new_diag.get("verdict") + except Exception: + pass + + # 3) top5_gated / top5_stable / compare_rows 각 row 의 overfit_risk_pct 도 Y안으로 재계산 + # (프론트 배지가 이 row 별 값을 봄 · optunaRenderOverfitBadges) + if needs_diag: + for row_list in (top5g, top5s): + for row in row_list or []: + if not isinstance(row, dict): + continue + try: + of = overfit_risk_pct_for_row(d, row) + if of.get("overfit_risk_pct") is not None: + row["overfit_risk_pct"] = of.get("overfit_risk_pct") + row["overfit_verdict"] = of.get("verdict") + row["overfit_verdict_ui"] = of.get("verdict_ui") + except Exception: + pass + # compare_rows (mode 배지용) + for row in rs.get("compare_rows") or []: + if not isinstance(row, dict): + continue + try: + of = overfit_risk_pct_for_row(d, row) + if of.get("overfit_risk_pct") is not None: + row["overfit_risk_pct"] = of.get("overfit_risk_pct") + row["overfit_verdict"] = of.get("verdict") + row["overfit_verdict_ui"] = of.get("verdict_ui") + except Exception: + pass + + @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 + _inject_extended_params_fallback(meta) rs = meta.get("result_summary") or {} return jsonify({ "ok": True, @@ -10126,6 +10239,80 @@ def api_optuna_delete_bulk(): return jsonify({"ok": False, "error": str(e)}), 400 +@app.route("/api/optuna/log/", methods=["GET"]) +def api_optuna_log(job_id: str): + """옵투나 잡 로그 tail (모달 전용 · 룰 19 사용자 요청 2026-09-06). + + 쿼리: + - lines=N (기본 500, 최대 5000): 뒤에서 N줄만 + - refine_phase=phase1|phase2|main (기본 auto — active_log 사용) + + 응답: {ok, job_id, log_path, lines, text, size_bytes, updated_at} + """ + from kis_trader.backtest import optuna_web_jobs as owj + + try: + meta = owj.load_job(job_id) + except Exception as e: + return jsonify({"ok": False, "error": f"load 실패: {e}"}), 400 + if not meta: + return jsonify({"ok": False, "error": "없는 job_id"}), 404 + + try: + lines = int(request.args.get("lines", 500)) + except (TypeError, ValueError): + lines = 500 + lines = max(50, min(5000, lines)) + + # 어느 로그 파일을 쓸지: refine_phase 지정 시 그것, 아니면 active_log + phase = (request.args.get("refine_phase") or "").strip().lower() + log_path = "" + rs = meta.get("refine_state") if isinstance(meta.get("refine_state"), dict) else {} + if phase == "phase1": + log_path = str(rs.get("phase1_log") or meta.get("log_path") or "") + elif phase == "phase2": + log_path = str(rs.get("phase2_log") or meta.get("log_path") or "") + else: + # active_log 우선 (refine 진행 중이면 현재 phase 자동) + log_path = str( + meta.get("active_log") + or rs.get("phase2_log") + or rs.get("phase1_log") + or meta.get("log_path") + or "" + ) + + from pathlib import Path + + if not log_path or not Path(log_path).is_file(): + return jsonify({ + "ok": True, + "job_id": job_id, + "log_path": log_path, + "lines": 0, + "text": "", + "size_bytes": 0, + "updated_at": None, + "note": "로그 파일 없음 (아직 시작 안 됐거나 삭제됨)", + }) + + try: + text = owj._tail_text(log_path, lines) + stat = Path(log_path).stat() + return jsonify({ + "ok": True, + "job_id": job_id, + "log_path": log_path, + "lines": len(text.splitlines()), + "text": text, + "size_bytes": int(stat.st_size), + "updated_at": stat.st_mtime, + "phase": phase or "auto", + }) + except Exception as e: + return jsonify({"ok": False, "error": str(e)}), 500 + + @app.route("/api/optuna/ob_bias_hints", methods=["GET"]) def api_optuna_ob_bias_hints(): """전략별 최근 Optuna → 호가 ON/OFF 몰림 (웹 폼 녹색 표시용).""" diff --git a/kis_trader/backtest/optuna_common.py b/kis_trader/backtest/optuna_common.py index 434aa55..b1008c8 100644 --- a/kis_trader/backtest/optuna_common.py +++ b/kis_trader/backtest/optuna_common.py @@ -845,12 +845,15 @@ def overfit_risk_pct_for_row( days = _optuna_overfit_sample_days(data or {}) risk = 0.0 + # 2026-09-06 Y안 (룰 19 사용자 선택): 표본 일수 max 40 → 25 감소. + # 근거: 다일 확보가 어려운 개발 초기·특정 종목 대응·리허설 백테에서 견고성 지표(팩터 5·6) + # 로 상쇄 가능해야. 이전엔 1일=자동 40점+ → 진짜 견고한 조합도 비권장 뜨는 부작용. if days <= 1: - risk += 40.0 + risk += 25.0 elif days == 2: - risk += 28.0 + risk += 18.0 elif days <= 4: - risk += 16.0 + risk += 10.0 if not row: risk = max(0.0, min(100.0, round(risk + 25.0, 1))) @@ -954,13 +957,14 @@ def build_optuna_overfit_diagnostics(data: Dict[str, Any]) -> Dict[str, Any]: factors: List[Dict[str, Any]] = [] risk = 0.0 - # 1) 표본 장일 + # 1) 표본 장일 (2026-09-06 Y안 · 룰 19: max 40 → 25 감소) + # 다일 확보 어려운 개발/리허설·특정 종목 백테는 견고성 지표(팩터 6)로 상쇄 가능해야 함. if days <= 1: - pts, detail = 40.0, f"거래일≈{days}일 — 단일 장 과적합 위험 최대" + pts, detail = 25.0, f"거래일≈{days}일 — 단일 장 표본 부족(견고성으로 상쇄 필요)" elif days == 2: - pts, detail = 28.0, f"거래일≈{days}일 — 이틀만으로는 추세 전환에 취약" + pts, detail = 18.0, f"거래일≈{days}일 — 이틀만으로는 추세 전환에 취약" elif days <= 4: - pts, detail = 16.0, f"거래일≈{days}일 — 다일 재검증 권장(≥5일)" + pts, detail = 10.0, f"거래일≈{days}일 — 다일 재검증 권장(≥5일)" else: pts, detail = 0.0, f"거래일≈{days}일 — 표본 일수 상대적 양호" risk += pts @@ -1021,9 +1025,15 @@ def build_optuna_overfit_diagnostics(data: Dict[str, Any]) -> Dict[str, Any]: risk += pts factors.append({"id": "gate_coverage", "label": "게이트 커버", "points": pts, "detail": detail}) - # 5) PnL 고원(동일 best 반복) + # 5) PnL 고원(동일 best 반복) — 2026-09-06 Y안 재해석 (룰 19 사용자 선택) + # 기존: 동일 PnL 반복 = 무조건 위험 (+12점) + # 정정: 동일 PnL 반복 & **파라미터도 좁음** = TPE 좁게 튐 (여전히 위험 · 최대 +12) + # 동일 PnL 반복 & **파라미터 다양** = 파라미터 민감도 낮음 = 견고 (감점 -8) + # 판단: same pool 에서 핵심 파라미터(tp_pct/sl_pct/drop_rate/vol_mult) 의 + # 고유값 개수 대비 표본 크기 비율(unique_ratio)로 근사. plateau_share = 0.0 plateau_n = 0 + plateau_param_diverse = False if pool and top: best_pnl = round(pnl, 0) same = [ @@ -1032,22 +1042,99 @@ def build_optuna_overfit_diagnostics(data: Dict[str, Any]) -> Dict[str, Any]: ] plateau_n = len(same) plateau_share = plateau_n / max(1, len(pool)) + # 핵심 파라미터의 고유값 다양성 (같은 PnL 이 여러 파라미터 조합에서 도달했나?) + _core_keys = ("tp_pct", "sl_pct", "drop_rate", "vol_mult", "cooldown_min") + _uniq_ratios: List[float] = [] + for k in _core_keys: + vals = [] + for r in same: + p = r.get("merged_params") or r.get("params") or {} + if isinstance(p, dict) and k in p: + try: + vals.append(round(float(p[k]), 6)) + except (TypeError, ValueError): + pass + if len(vals) >= 3: + _uniq_ratios.append(len(set(vals)) / len(vals)) + _avg_uniq = sum(_uniq_ratios) / max(1, len(_uniq_ratios)) if _uniq_ratios else 0.0 + # unique_ratio 0.5+ = 다양한 파라미터에서 같은 PnL 도달 = 견고 + plateau_param_diverse = _avg_uniq >= 0.5 and len(_uniq_ratios) >= 3 + if plateau_share >= 0.4 and plateau_n >= 5: - pts = 12.0 - detail = ( - f"동일 PnL≈{best_pnl:,.0f}원이 후보 {plateau_n}/{len(pool)} " - f"({plateau_share:.0%}) — 파라미터 민감도 낮음/고원" - ) + if plateau_param_diverse: + pts = -8.0 + detail = ( + f"동일 PnL≈{best_pnl:,.0f}원이 {plateau_n}/{len(pool)} " + f"({plateau_share:.0%}) · 핵심 파라미터 다양성 {_avg_uniq:.0%} — 견고(-8)" + ) + else: + pts = 12.0 + detail = ( + f"동일 PnL≈{best_pnl:,.0f}원이 {plateau_n}/{len(pool)} " + f"({plateau_share:.0%}) · 파라미터 좁음({_avg_uniq:.0%}) — TPE 몰빵/위험" + ) elif plateau_share >= 0.25 and plateau_n >= 3: - pts = 6.0 - detail = f"PnL 고원 {plateau_n}/{len(pool)} ({plateau_share:.0%})" + if plateau_param_diverse: + pts = -4.0 + detail = ( + f"PnL 고원 {plateau_n}/{len(pool)} ({plateau_share:.0%}) · " + f"파라미터 다양({_avg_uniq:.0%}) — 소폭 견고(-4)" + ) + else: + pts = 6.0 + detail = f"PnL 고원 {plateau_n}/{len(pool)} ({plateau_share:.0%}) · 파라미터 좁음" else: pts = 0.0 detail = f"고원 비율 {plateau_share:.0%} ({plateau_n}건)" else: pts, detail = 0.0, "고원 판정 스킵" risk += pts - factors.append({"id": "pnl_plateau", "label": "PnL 고원", "points": pts, "detail": detail}) + factors.append({"id": "pnl_plateau", "label": "PnL 고원 (파라미터 민감도)", "points": pts, "detail": detail}) + + # 6) 파라미터 안정성 (2026-09-06 Y안 신설 · 사용자 지적: 녹색줄+주황점 일치=견고) + # gated pool 전체에서 핵심 파라미터의 mode_share (최빈값 비율) 를 봄. + # mode_share ≥ 0.6 인 파라미터가 여러 개면 = "여러 trial이 같은 값 선택" = 견고 → 감점 + # 판정: threshold_distribution 사전 계산 안 됐을 수 있어 pool 에서 직접 집계. + core_keys_stab = ( + "tp_pct", "sl_pct", "drop_rate", "vol_mult", "cooldown_min", + "shoulder_min_high", "shoulder_cut_pct", "high_chase_thr", + "whipsaw_subbar_sec", "whipsaw_lookback_sec", + ) + stab_stats: List[Tuple[str, float, int]] = [] # (key, mode_share, n) + pool_for_stab = pool[: min(30, len(pool))] if pool else [] + if pool_for_stab: + for k in core_keys_stab: + vals: List[float] = [] + for r in pool_for_stab: + p = r.get("merged_params") or r.get("params") or {} + if isinstance(p, dict) and k in p: + try: + vals.append(round(float(p[k]), 6)) + except (TypeError, ValueError): + pass + if len(vals) >= 3: + mode_v = max(set(vals), key=vals.count) + mode_n = sum(1 for v in vals if v == mode_v) + stab_stats.append((k, mode_n / len(vals), len(vals))) + strong_stab = [s for s in stab_stats if s[1] >= 0.6] + if len(strong_stab) >= 5: + pts = -15.0 + detail = ( + f"핵심 파라미터 {len(strong_stab)}개가 mode_share≥60% — " + f"다수 trial 동일값 선택 = 매우 견고(-15)" + ) + elif len(strong_stab) >= 3: + pts = -8.0 + detail = f"핵심 파라미터 {len(strong_stab)}개가 mode_share≥60% — 다소 견고(-8)" + elif len(stab_stats) >= 3: + pts = 0.0 + detail = ( + f"핵심 파라미터 {len(stab_stats)}개 집계, 견고 {len(strong_stab)}개 — 중립" + ) + else: + pts, detail = 0.0, "표본 부족 — 안정성 판정 스킵" + risk += pts + factors.append({"id": "param_stability", "label": "파라미터 안정성", "points": pts, "detail": detail}) risk = max(0.0, min(100.0, round(risk, 1))) apply_pct = max(0.0, min(100.0, round(100.0 - risk, 1))) diff --git a/kis_trader/backtest/optuna_web_jobs.py b/kis_trader/backtest/optuna_web_jobs.py index d4dfb35..e1107f2 100644 --- a/kis_trader/backtest/optuna_web_jobs.py +++ b/kis_trader/backtest/optuna_web_jobs.py @@ -2186,6 +2186,24 @@ def _ob_whip_ui_from_params(params: Optional[Dict[str, Any]]) -> Dict[str, Any]: elif whip_on is False and (ob_on is not None or exit_bits): lines.append("휩쏘OFF") + # 2026-09-06 (룰 19 사용자 요청): 사후합격 Top5 카드용 추가 파라미터 flat 추출 + # 프론트 optunaFormatAvgHtml 에서 핫핑크 그라데이션 대상 확장. + # 그룹: 핵심 진입 · 핵심 청산 · RSI · 어깨 + entry_drop_rate = _f("drop_rate") + entry_vol_mult = _f("vol_mult") + entry_high_chase = _f("high_chase_thr") + + exit_min_hold_sec = _i("min_hold_sec") + exit_cooldown_min = _i("cooldown_min") + exit_max_loss_krw = _f("max_loss_krw") + + rsi_period = _i("rsi_period") + rsi_oversold = _f("rsi_oversold") + rsi_overbought = _f("rsi_overbought") + + shoulder_min_high = _f("shoulder_min_high") + shoulder_cut_pct = _f("shoulder_cut_pct") + return { "ob_on": ob_on, "whip_on": whip_on, @@ -2199,6 +2217,18 @@ def _ob_whip_ui_from_params(params: Optional[Dict[str, Any]]) -> Dict[str, Any]: "whip_lookback_sec": whip_lb, "whip_dip_pct": whip_dip, "whip_recovery_tol_pct": whip_tol, + # 추가 파라미터 (2026-09-06) + "entry_drop_rate": entry_drop_rate, + "entry_vol_mult": entry_vol_mult, + "entry_high_chase": entry_high_chase, + "exit_min_hold_sec": exit_min_hold_sec, + "exit_cooldown_min": exit_cooldown_min, + "exit_max_loss_krw": exit_max_loss_krw, + "rsi_period": rsi_period, + "rsi_oversold": rsi_oversold, + "rsi_overbought": rsi_overbought, + "shoulder_min_high": shoulder_min_high, + "shoulder_cut_pct": shoulder_cut_pct, "ob_summary": " · ".join(lines) if lines else None, "ob_summary_lines": lines, } diff --git a/logs/optuna_latest.jsonpath b/logs/optuna_latest.jsonpath index 745059a..1525dd5 100644 --- a/logs/optuna_latest.jsonpath +++ b/logs/optuna_latest.jsonpath @@ -1 +1 @@ -/home/hoon/kis_bot/kis_trader/backtest/results/optuna_scalp_tpe_20260906_131534.json +/home/hoon/kis_bot/kis_trader/backtest/results/optuna_scalp_tpe_20260906_205842.json diff --git a/logs/optuna_scalp_latest.jsonpath b/logs/optuna_scalp_latest.jsonpath index 745059a..1525dd5 100644 --- a/logs/optuna_scalp_latest.jsonpath +++ b/logs/optuna_scalp_latest.jsonpath @@ -1 +1 @@ -/home/hoon/kis_bot/kis_trader/backtest/results/optuna_scalp_tpe_20260906_131534.json +/home/hoon/kis_bot/kis_trader/backtest/results/optuna_scalp_tpe_20260906_205842.json diff --git a/logs/optuna_scalp_tpe_latest.jsonpath b/logs/optuna_scalp_tpe_latest.jsonpath index 745059a..1525dd5 100644 --- a/logs/optuna_scalp_tpe_latest.jsonpath +++ b/logs/optuna_scalp_tpe_latest.jsonpath @@ -1 +1 @@ -/home/hoon/kis_bot/kis_trader/backtest/results/optuna_scalp_tpe_20260906_131534.json +/home/hoon/kis_bot/kis_trader/backtest/results/optuna_scalp_tpe_20260906_205842.json diff --git a/logs/optuna_web_latest_job.txt b/logs/optuna_web_latest_job.txt index 68f7061..b5487c9 100644 --- a/logs/optuna_web_latest_job.txt +++ b/logs/optuna_web_latest_job.txt @@ -1 +1 @@ -opt_20260906_162743_refine_scal +opt_20260906_205103_refine_scal diff --git a/static/js/backtest.js b/static/js/backtest.js index f4c4578..6246e88 100644 --- a/static/js/backtest.js +++ b/static/js/backtest.js @@ -8969,7 +8969,21 @@ function optunaFormatGatedTopRowHtml(t, i, showBorder, 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']; + // 2026-09-06 (룰 19 사용자 요청): 사후합격 Top5 카드에 추가 파라미터 그라데이션 대상 + // 그룹: 호가 · 익절/손절 · 휩쏘 · 핵심진입 · 핵심청산 · RSI · 어깨 + 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', + // 핵심 진입 + 'entry_drop_rate', 'entry_vol_mult', 'entry_high_chase', + // 핵심 청산 + 'exit_min_hold_sec', 'exit_cooldown_min', 'exit_max_loss_krw', + // RSI + 'rsi_period', 'rsi_oversold', 'rsi_overbought', + // 어깨 + 'shoulder_min_high', 'shoulder_cut_pct', + ]; const stats = {}; keys.forEach(k => { let sum = 0, cnt = 0, min = Infinity, max = -Infinity; @@ -8993,27 +9007,50 @@ function optunaCalcStats(list) { function optunaFormatAvgHtml(stats, len) { if (!stats || Object.keys(stats).length === 0) return ''; - const avgFmt = (k, prefix, suffix, dec) => { + // 2026-09-06 (룰 19 사용자 요청 cap_all): 영문 변수명 옆 한글 캡션 · 신규 그룹 추가 + // avgFmt(key, prefix, suffix, dec, korCap) — korCap 있으면 "prefix(korCap)value" 형식 + const avgFmt = (k, prefix, suffix, dec, korCap) => { if (!stats[k]) return ''; - return `${prefix}${stats[k].avg.toFixed(dec)}${suffix} `; + const cap = korCap ? `(${korCap})` : ''; + return `${prefix}${cap}${stats[k].avg.toFixed(dec)}${suffix} `; }; + const hasEntry = stats['entry_drop_rate'] || stats['entry_vol_mult'] || stats['entry_high_chase']; + const hasExit = stats['exit_min_hold_sec'] || stats['exit_cooldown_min'] || stats['exit_max_loss_krw']; + const hasRsi = stats['rsi_period'] || stats['rsi_oversold'] || stats['rsi_overbought']; + const hasShoulder = stats['shoulder_min_high'] || stats['shoulder_cut_pct']; return `
수치 평균 (Top${len} 기준 · 근접시 진하게)
- ${stats['ob_spread'] ? `호가ON ` : ''} - ${avgFmt('ob_spread', 'spr', '', 1)} - ${avgFmt('ob_ratio', 'r', '', 2)} - ${avgFmt('ob_ask', 'ask×', '', 1)} + ${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)} + ${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, '복원허용')} + ${hasEntry ? `
핵심진입 ` : ''} + ${avgFmt('entry_drop_rate', 'drop', '%', 1, '최소낙폭')} + ${avgFmt('entry_vol_mult', 'vol×', '', 2, '거래량배수')} + ${avgFmt('entry_high_chase', 'chase', '', 2, '고점추격임계')} + ${hasExit ? `
핵심청산 ` : ''} + ${avgFmt('exit_min_hold_sec', 'hold', 's', 0, '최소보유초')} + ${avgFmt('exit_cooldown_min', 'cool', 'm', 0, '쿨다운분')} + ${avgFmt('exit_max_loss_krw', 'maxL', '원', 0, '최대손실액')} + ${hasRsi ? `
RSI ` : ''} + ${avgFmt('rsi_period', 'p', '', 0, 'RSI기간')} + ${avgFmt('rsi_oversold', 'lo', '', 1, '과매도')} + ${avgFmt('rsi_overbought', 'hi', '', 1, '과매수')} + ${hasShoulder ? `
어깨 ` : ''} + ${avgFmt('shoulder_min_high', 'minH', '%', 1, '최소고점등락')} + ${avgFmt('shoulder_cut_pct', 'cut', '%', 2, '어깨컷')}
`; } @@ -9814,6 +9851,96 @@ function optunaCandidateFormParams(j) { return j.params_full || j.params_preview || {}; } +// 2026-09-06 (룰 19 사용자 요청): 로그 모달 (500줄 tail + 검색 + auto-scroll) +// 기존 pre#opt_log_tail 은 25줄만이라 pruned 잔뜩이면 실제 완료 로그가 밀림 → 모달 확장 +let _optunaLogModalTimer = null; +let _optunaLogModalRawText = ''; // 필터 원본 +let _optunaLogModalAutoScroll = true; + +function optunaOpenLogModal() { + if (!_optunaJobId) { + alert('선택된 job 없음 — 최근 잡에서 「보기」로 job 을 먼저 고르세요'); + return; + } + const el = document.getElementById('opt_log_modal'); + if (!el || typeof bootstrap === 'undefined') { alert('모달 로드 실패 (bootstrap 없음)'); return; } + const jidEl = document.getElementById('opt_log_modal_jobid'); + if (jidEl) jidEl.textContent = `· job ${_optunaJobId}`; + const modal = bootstrap.Modal.getOrCreateInstance(el); + modal.show(); + optunaRefreshLogModal(); + // auto 켜지면 3초 주기 새로고침 + optunaSetupLogModalAutoRefresh(); + // 모달 닫힐 때 타이머 정리 + el.addEventListener('hidden.bs.modal', () => { + if (_optunaLogModalTimer) { clearInterval(_optunaLogModalTimer); _optunaLogModalTimer = null; } + }, { once: true }); +} + +function optunaSetupLogModalAutoRefresh() { + if (_optunaLogModalTimer) { clearInterval(_optunaLogModalTimer); _optunaLogModalTimer = null; } + const auto = document.getElementById('opt_log_modal_auto'); + if (!auto) return; + const startTimer = () => { + if (auto.checked && !_optunaLogModalTimer) { + _optunaLogModalTimer = setInterval(optunaRefreshLogModal, 3000); + } else if (!auto.checked && _optunaLogModalTimer) { + clearInterval(_optunaLogModalTimer); _optunaLogModalTimer = null; + } + }; + auto.onchange = startTimer; + startTimer(); +} + +async function optunaRefreshLogModal() { + if (!_optunaJobId) return; + const linesEl = document.getElementById('opt_log_modal_lines'); + const lines = linesEl ? Number(linesEl.value) || 500 : 500; + const bodyEl = document.getElementById('opt_log_modal_body'); + const metaEl = document.getElementById('opt_log_modal_meta'); + if (!bodyEl) return; + try { + const r = await fetch(`/api/optuna/log/${encodeURIComponent(_optunaJobId)}?lines=${lines}`); + const j = await r.json(); + if (!j.ok) { + bodyEl.textContent = `[에러] ${j.error || '알 수 없음'}`; + if (metaEl) metaEl.textContent = ''; + return; + } + _optunaLogModalRawText = String(j.text || ''); + if (metaEl) { + const sz = j.size_bytes ? (j.size_bytes / 1024).toFixed(1) + 'KB' : '-'; + const ln = j.lines != null ? j.lines : '-'; + const upd = j.updated_at ? new Date(j.updated_at * 1000).toLocaleTimeString() : '-'; + metaEl.textContent = `파일 ${j.log_path || '—'} · ${ln}줄 · ${sz} · 갱신 ${upd}`; + } + // 스크롤 위치 유지 여부 판단 (하단 근처면 auto-scroll) + const nearBottom = (bodyEl.scrollHeight - bodyEl.scrollTop - bodyEl.clientHeight) < 40; + optunaFilterLogModal(); // 검색 필터 적용해서 렌더 + if (nearBottom || _optunaLogModalAutoScroll) { + bodyEl.scrollTop = bodyEl.scrollHeight; + } + } catch (e) { + bodyEl.textContent = `[fetch 실패] ${e && e.message ? e.message : e}`; + } +} + +function optunaFilterLogModal() { + const bodyEl = document.getElementById('opt_log_modal_body'); + const searchEl = document.getElementById('opt_log_modal_search'); + if (!bodyEl) return; + const q = ((searchEl && searchEl.value) || '').trim().toLowerCase(); + if (!q) { + bodyEl.textContent = _optunaLogModalRawText; + return; + } + const lines = _optunaLogModalRawText.split('\n'); + const filtered = lines.filter(ln => ln.toLowerCase().includes(q)); + bodyEl.textContent = filtered.length + ? filtered.join('\n') + : `[검색] "${q}" 매칭 없음 (원본 ${lines.length}줄)`; +} + async function optunaShowCandidate(source, rank) { if (!_optunaJobId) { alert('선택된 job 없음 — 최근 잡에서「보기」로 job을 먼저 고르세요'); diff --git a/templates/backtest.html b/templates/backtest.html index cbe7272..395da8e 100644 --- a/templates/backtest.html +++ b/templates/backtest.html @@ -4313,7 +4313,53 @@
-

+      
+ 최근 로그 (마지막 25줄, 폴링 갱신) + +
+

+
+      
+