feat(backtest): 대대적인 Optuna 백테스트 웹 UI 및 백엔드 파이프라인 개편

- Web UI:
  - Optuna 탭 추가 및 mode_combo (최빈값 조합), 사후합격 Top 10 시각화 기능
  - 파라미터 분포(p25~p75, median, mode) 히스토그램 및 과적합(Overfit) 위험도 진단 UI 신설
  - 체크박스 렌더링 깨짐 현상을 네이티브(appearance: auto)로 강제 복구 (CSS)
  - 다단 트레일링 스탑, 꼬리 진입/돌파 손절 등 고급 조건 설정 폼 UI 고도화

- Backend (Optuna Jobs):
  - CLI 환경에서 구동된 Optuna json 결과물을 웹 대시보드로 읽어오는 import 기능 강화
  - JSON 메타데이터에 sort_by, mode, 호가 적용 여부 등 핵심 파라미터 파싱 누락 수정
  - optuna_mode_combo.py 등 최빈값 조합 및 후보군 2차 검증을 위한 신규 모듈 추가

- DB & Execution:
  - WebSocket 호가/틱 피드 수집 통계(api_feed_collect_stats) 메모리 캐시 최적화
  - KIS client 접속 키(approval_key) 등 인프라스트럭처 안정성 및 공유 관리 구조 개선
  - 테스트 및 디버깅용 briefing 마크다운 자동 생성 기능 추가
This commit is contained in:
Your Name
2026-09-01 02:47:51 +09:00
parent 3d0255519e
commit 9ba9ab73b6
92 changed files with 8211 additions and 487 deletions

View File

@@ -136,6 +136,197 @@ def _live_pnl_snapshot(strategy: str, start: str, end: str) -> str:
pass
def _params_exit_ob_line(params: Optional[Dict[str, Any]]) -> str:
"""trial params → 호가·익절·손절 한 줄 (브리핑용)."""
if not isinstance(params, dict):
return ""
try:
from kis_trader.backtest.optuna_web_jobs import _ob_whip_ui_from_params
ui = _ob_whip_ui_from_params(params)
return str(ui.get("ob_summary") or "")
except Exception:
tp = params.get("tp_pct") or params.get("take_profit_pct")
sl = params.get("sl_pct") or params.get("stop_loss_pct")
ob = params.get("ob_filter_enabled")
bits: List[str] = []
if ob is True:
bits.append("호가ON")
elif ob is False:
bits.append("호가OFF")
if tp is not None:
bits.append(f"익절{float(tp):.1f}%")
if sl is not None:
bits.append(f"손절{float(sl):.1f}%")
return " ".join(bits) if bits else ""
def _trial_metrics_line(row: Optional[Dict[str, Any]], *, tag: str) -> str:
if not isinstance(row, dict):
return f"- **{tag}**: 없음"
tn = row.get("optuna_trial_number")
tn_s = f"trial #{tn}" if tn is not None else "trial 없음(조립)"
pnl = _f(row.get("total_pnl"))
wr = _f(row.get("win_rate"))
pf = _f(row.get("pf"))
nt = _i(row.get("total_trades"))
stab = row.get("stability_score")
stab_s = f" · 안정점수 {float(stab):.0f}" if stab is not None else ""
prox = row.get("consensus_match_pct")
prox_s = ""
if prox is not None:
mn = row.get("consensus_match_n")
mo = row.get("consensus_match_of")
prox_s = f" · 근접 {prox}%"
if mn is not None and mo is not None:
prox_s += f" ({mn}/{mo}축 밴드안)"
prm = row.get("params") or row.get("merged_params") or {}
ob_line = _params_exit_ob_line(prm if isinstance(prm, dict) else {})
return (
f"- **{tag}** ({tn_s}): PnL {pnl:,.0f}원 · WR {wr:.1f}% · PF {pf:.2f} · "
f"거래 {nt}{stab_s}{prox_s} · {ob_line}"
)
def build_final_selection_briefing_lines(data: Dict[str, Any]) -> List[str]:
"""
mode Top10 / mode_combo / gated / stable 기준 최종 선택 후보 (규칙 기반).
웹 하단 표와 동일 JSON 소스 — apply source 명시.
"""
from kis_trader.backtest.optuna_common import (
resolve_results_mode_consensus,
resolve_results_stable,
)
from kis_trader.backtest.optuna_mode_combo import (
resolve_mode_pool_kind,
select_mode_pool_rows,
_build_mode_band_profile,
_row_param_value,
)
from kis_trader.backtest.optuna_postprocess_topn import resolve_post_top_n
lines: List[str] = []
lines.append("## 최종 선택 후보 (규칙 · DB 적용 전 확인)")
lines.append(
"- 아래는 **역할이 다른** 4종 후보입니다. mode Top10 1위 ≠ 익절 대표 · "
"**DB 1순위는 사후합격(gated)** 입니다."
)
pool_kind = resolve_mode_pool_kind()
lines.append(f"- mode pool: `{pool_kind}` · scoring: `band_proximity_p25_p75`")
lines.append("")
top_n = resolve_post_top_n(10)
gated = list(data.get("results_gated") or [])
learn = list(data.get("results") or data.get("results_all") or [])
stable, stable_meta = resolve_results_stable(data, top_n=top_n)
mode_rows, mode_meta = resolve_results_mode_consensus(data, top_n=top_n)
mc = data.get("mode_combo") if isinstance(data.get("mode_combo"), dict) else {}
mc_bt = mc.get("backtest") if isinstance(mc.get("backtest"), dict) else {}
mc_params = mc.get("params") if isinstance(mc.get("params"), dict) else {}
g0 = gated[0] if gated else None
s0 = stable[0] if stable else None
m0 = mode_rows[0] if mode_rows else None
lines.append("### 후보 4종")
lines.append(_trial_metrics_line(g0, tag="① 사후합격 1위 (apply: gated)"))
lines.append(_trial_metrics_line(s0, tag="② 안정 1위 (apply: stable)"))
if mc_params or mc_bt:
mode_row = {
"optuna_trial_number": None,
"total_pnl": mc_bt.get("total_pnl"),
"win_rate": mc_bt.get("win_rate"),
"pf": mc_bt.get("pf"),
"total_trades": mc_bt.get("total_trades"),
"stability_score": mc_bt.get("stability_score"),
"params": mc_params,
}
freq_tp = (mc.get("freq") or {}).get("tp_pct") if isinstance(mc.get("freq"), dict) else None
extra = ""
if isinstance(freq_tp, dict) and freq_tp.get("value") is not None:
extra = f" · tp 최빈 {freq_tp.get('value')}% ({freq_tp.get('count')}/{freq_tp.get('of')})"
lines.append(_trial_metrics_line(mode_row, tag="③ mode_combo (apply: mode)") + extra)
else:
lines.append("- **③ mode_combo (apply: mode)**: 없음 (미산출·구 JSON)")
lines.append(_trial_metrics_line(m0, tag="④ mode Top10 1위 (apply: consensus · 밴드 전형 trial)"))
# tp 밴드 — mode Top10 1위가 tp 밖인지
if m0 and mode_meta.get("mode_pool_size"):
try:
pool = select_mode_pool_rows(learn, data=data)
keys: List[str] = list(data.get("grid_keys") or [])
if not keys and pool:
keys = list((pool[0].get("params") or {}).keys())
prof = _build_mode_band_profile(pool, keys)
tp_band = prof.get("tp_pct") or prof.get("take_profit_pct")
if tp_band and tp_band.get("kind") == "numeric":
p25 = float(tp_band["p25"])
p75 = float(tp_band["p75"])
prm = m0.get("params") or {}
tp_v = _row_param_value(m0, "tp_pct") or _row_param_value(m0, "take_profit_pct")
if tp_v is not None:
tp_f = float(tp_v)
in_band = p25 <= tp_f <= p75
lines.append("")
lines.append(
f"- mode pool tp_pct 밴드 p25~p75: **{p25:g}~{p75:g}%** · "
f"Top10 1위 tp={tp_f:g}% → "
f"{'밴드 **안**' if in_band else '밴드 **밖**(다른 축 보정으로 근접% 높음)'}"
)
except Exception:
pass
if mode_meta.get("mode_pool_size") is not None:
lines.append(
f"- mode Top10 meta: pool {mode_meta.get('mode_pool_size')}건 · "
f"{mode_meta.get('band_axes') or mode_meta.get('mode_params_keys')}"
)
lines.append("")
lines.append("### 추천 (자동 · confirm 필수)")
if g0:
lines.append(
"1. **실매 DB 적용 1순위 → ① 사후합격 1위** (`optunaApply gated`) — "
"WR/PF·min_trades 사후 통과."
)
if s0 and s0 is not g0:
lines.append(
"2. **변동성·손실일 줄이기 → ② 안정 1위** (`stable`) — "
"PnL보다 일별 안정 우선 시."
)
else:
lines.append(
"1. **사후합격 0건 → DB 적용 비권장.** 현행 DB 유지 + 유니버스·틱·기간 재검증."
)
if learn:
lines.append(
"2. 참고만: 학습 1위는 objective 최대일 뿐 사후 게이트 미통과일 수 있음."
)
if mc_params:
lines.append(
f"{'3' if g0 else '2'}. **2차 narrow·대표 숫자 → ③ mode_combo** — "
"축별 최빈 조립 · trial 번호 없음 · 익절은 pool 최빈값 참고."
)
if m0:
n = "4" if (g0 and mc_params) else ("3" if (g0 or mc_params) else "2")
lines.append(
f"{n}. **④ mode Top10 1위** — 양수 pool 전형 trial(다축 밴드 근접). "
"**익절 하나로 쓰지 말 것** · 표에서 「보기」→ consensus apply."
)
diag = data.get("overfit_diagnostics") if isinstance(data.get("overfit_diagnostics"), dict) else {}
risk = diag.get("overfit_risk_pct")
if risk is not None and float(risk) >= 55 and g0:
lines.append(
f"- ⚠ 과적합 위험 {risk}% — gated 적용 전 **웹백테 동일 기간 1회**·소액 관찰 권장."
)
if stable_meta.get("fallback_rank_only"):
lines.append(
f"- ⚠ 안정 게이트 0건 → 안정 Top은 **점수순 폴백** ({stable_meta.get('fallback_note', '')})"
)
lines.append("")
return lines
def build_rule_briefing(data: Dict[str, Any]) -> str:
"""규칙 기반 — 이전 장 / 앞으로 장 코멘트."""
strategy = str(data.get("strategy") or "tail").strip().lower()
@@ -227,6 +418,8 @@ def build_rule_briefing(data: Dict[str, Any]) -> str:
lines.append(f"- _참고: {diag.get('note')}_")
lines.append("")
lines.extend(build_final_selection_briefing_lines(data))
lines.append("## 이전 장에서는")
live = _live_pnl_snapshot(strategy, start, end)
if live:
@@ -306,13 +499,18 @@ def _briefing_prompt(rule_text: str, data: Dict[str, Any]) -> str:
"optuna_best_value": data.get("optuna_best_value"),
"top_gated": (data.get("results_gated") or [None])[0],
"top_learning": (data.get("results") or [None])[0],
"top_stable": (data.get("results_stable") or [None])[0],
"top_mode_consensus": (data.get("results_mode") or [None])[0],
"mode_combo_params": (data.get("mode_combo") or {}).get("params"),
"mode_combo_vs_best": (data.get("mode_combo") or {}).get("vs_best"),
"mode_consensus_meta": data.get("mode_consensus_meta"),
}
return (
"당신은 한국 주식 퀀트 헤지펀드 리스크 매니저입니다. "
"아래 Optuna TPE 결과와 규칙 브리핑을 읽고, 초보자도 이해하게 "
"「이전 장에서는」/「앞으로 장에서는」 두 절로만 한국어 코멘트를 쓰세요. "
"과적합·표본부족·실매↔백테 괴리(고스트퍼지 등)를 분명히 경고하세요. "
"mode Top10 1위를 익절 대표로 단정하지 마세요 — DB 1순위는 사후합격(gated). "
"특정 종목 매수 추천·확정 수익 약속 금지. 200~400자.\n\n"
f"[규칙 브리핑]\n{rule_text}\n\n"
f"[요약 JSON]\n{json.dumps(compact, ensure_ascii=False, default=str)[:6000]}"