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:
@@ -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]}"
|
||||
|
||||
Reference in New Issue
Block a user