Files
kis_trader/scratch/current_diff.patch

5339 lines
238 KiB
Diff
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
diff --git a/backtest_web.py b/backtest_web.py
index 50eb6ec..3eee1ab 100644
--- a/backtest_web.py
+++ b/backtest_web.py
@@ -9854,6 +9854,17 @@ def api_optuna_stop(job_id: str):
return jsonify({"ok": False, "error": str(e)}), 400
+@app.route("/api/optuna/ob_bias_hints", methods=["GET"])
+def api_optuna_ob_bias_hints():
+ """전략별 최근 Optuna → 호가 ON/OFF 몰림 (웹 폼 녹색 표시용)."""
+ from kis_trader.backtest import optuna_web_jobs as owj
+
+ try:
+ return jsonify(owj.get_ob_bias_hints())
+ except Exception as e:
+ return jsonify({"ok": False, "error": str(e)}), 500
+
+
@app.route("/api/optuna/defaults", methods=["GET"])
def api_optuna_defaults():
"""날짜 기본값(거래일)."""
@@ -9872,6 +9883,7 @@ def api_optuna_defaults():
"mode": "tpe",
"strategies": ["momentum", "tail", "breakout", "scalp", "all"],
"note": "apply-best 없음. 탐색 게이트 WR/PF=0, 사후 results_gated.",
+ "period_hint": "전략 1개 「시작」= 1차(넓은 Grid) → 2차(밴드 축소) 자동 연쇄. 기간=위 시작·종료 date.",
})
diff --git a/database.py b/database.py
index ca4f44d..f488bee 100644
--- a/database.py
+++ b/database.py
@@ -303,6 +303,11 @@ ENV_CONFIG_KEYS = (
"TAIL_LIMIT_VALID_BARS", "TAIL_LIMIT_FILL_SLIP_PCT",
"TAIL_MIN_INVEST_RATIO_OF_SLOT",
"TAIL_PARAM_SEARCH_ENTRY_MODE",
+ "OPTUNA_MODE_POOL",
+ "OPTUNA_MODE_TOP_N",
+ "OPTUNA_MODE_BAND_DECAY_IQR",
+ "OPTUNA_MODE_REFINE_PHASE2_TRIALS",
+ "OPTUNA_MODE_REFINE_BAND_EXPAND_IQR",
"TAIL_GRID_FAST_MAX_DAILY_CHG", "TAIL_GRID_COARSE_MAX_DAILY_CHG",
"TAIL_GRID_FAST_SYMBOL_LOSS_PCT", "TAIL_GRID_FAST_SYMBOL_LOSS_KRW",
"TAIL_GRID_FAST_REENTRY_MIN_EDGE",
@@ -830,6 +835,10 @@ ENV_CONFIG_KEYS = (
"PSBL_RVSECNCL_TR_ID",
"PSBL_RVSECNCL_INQR_DVSN_1",
"PSBL_RVSECNCL_INQR_DVSN_2",
+ "CANCELABLE_CCLD_MAX_PAGES",
+ "SELL_LOCKED_ENQUEUE_COOLDOWN_SEC",
+ "CANCELABLE_RECONCILE_ENABLED",
+ "CANCELABLE_RECONCILE_CANCEL_RETRY",
# 잔고 연속조회 최대 페이지 (1p=실전50/모의20종목) — 보유 많을 때 누락 방지
"BALANCE_MAX_PAGES",
# WebSocket 실시간 가격 캐시 유효기간(초): 이 시간 이상 지나면 REST 재조회
@@ -4519,8 +4528,19 @@ class TradeDB:
return saved
def _insert_env_auth_row(self, snapshot: Dict[str, Any], created_at: str) -> Optional[int]:
- """env_auth_config 1행 INSERT (앱키/시크릿/ID/계좌 전용)."""
+ """env_auth_config 1행 INSERT (앱키/시크릿/ID/계좌 전용).
+
+ snapshot 에 실제 값이 있는 인증키가 하나도 없으면 빈 행 방지를 위해 INSERT 생략.
+ """
try:
+ # 빈 행 방지 가드: 실제 값을 가진 키가 하나도 없으면 저장 생략
+ has_any = any(
+ snapshot.get(k) is not None and str(snapshot.get(k)).strip() != ""
+ for k in ENV_AUTH_KEYS
+ )
+ if not has_any:
+ logger.debug("env_auth_config INSERT 생략 — snapshot에 인증키 값 없음 (빈 행 방지)")
+ return None
key_list = ", ".join(f"`{k}`" for k in ENV_AUTH_KEYS)
placeholders = ", ".join(["%s"] * (1 + len(ENV_AUTH_KEYS)))
vals = [created_at] + [snapshot.get(k, None) for k in ENV_AUTH_KEYS]
diff --git a/docs/like_mcp.md/db_erd.md b/docs/like_mcp.md/db_erd.md
index 4613e22..ae514d5 100644
--- a/docs/like_mcp.md/db_erd.md
+++ b/docs/like_mcp.md/db_erd.md
@@ -263,6 +263,10 @@ CREATE TABLE `orders` (
`filled_at` varchar(30) DEFAULT NULL,
`raw_json` mediumtext DEFAULT NULL,
`is_mock` tinyint(1) DEFAULT NULL,
+ `broker_open_qty` int(11) DEFAULT NULL,
+ `broker_open_odno` varchar(30) DEFAULT NULL,
+ `broker_reconcile_at` varchar(30) DEFAULT NULL,
+ `broker_reconcile_note` varchar(200) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_ord_no_ctx` (`ord_no`,`strategy_id`,`code`,`side`,`ord_date`,`is_mock`),
KEY `idx_strategy_date` (`strategy_id`,`ord_date`),
@@ -2756,7 +2760,11 @@ CREATE TABLE `config_us_momentum` (
| `INTRADAY_HOLDINGS_DRIFT_AUTO_RECOVER` | `text` | NULL | NULL | 드리프트 자동복구 · OFF=알림만 · ON=active_trades qty 보정(기본 OFF) |
| `GHOST_PURGE_ON_RECONCILE` | `text` | NULL | NULL | 고아복구 시 유령잔고 삭제 · ON=브로커 0주인데 active_trades 남은 종목 삭제(수동보호 제외) · Pre/Post EOD 동일 잔고조회에서 처리 |
| `INQUIRE_PSBL_RVSECNCL_BEFORE_GHOST` | `text` | NULL | NULL | 유령정리 전 정정취소가능주문조회. 매도가능 0 ≠ 보유 0 |
-| `PSBL_RVSECNCL_CACHE_TTL_SEC` | `text` | NULL | NULL | 정정취소가능 조회 캐시(초). 기본 5 |
+| `PSBL_RVSECNCL_CACHE_TTL_SEC` | `text` | NULL | NULL | 정정취소가능 조회 캐시(초). 기본 30 |
+| `CANCELABLE_CCLD_MAX_PAGES` | `text` | NULL | NULL | 모의 cancelable daily-ccld 페이지 상한. 기본 3 |
+| `SELL_LOCKED_ENQUEUE_COOLDOWN_SEC` | `text` | NULL | NULL | sell_locked enqueue 쿨다운(초). 기본 20 |
+| `CANCELABLE_RECONCILE_ENABLED` | `text` | NULL | NULL | cancelable_open DB↔브로커 reconcile ON/OFF |
+| `CANCELABLE_RECONCILE_CANCEL_RETRY` | `text` | NULL | NULL | reconcile 시 cancel_order 재시도 |
| `PSBL_RVSECNCL_MAX_PAGES` | `text` | NULL | NULL | 정정취소가능 연속조회 최대 페이지. 기본 5 |
| `WS_GAP_ROLLUP_3M_FROM_1M` | `text` | NULL | NULL | 공통env: WS GAP ROLLUP 3M FROM 1M (WS_GAP_ROLLUP_3M_FROM_1M) |
| `WS_GAP_FILL_CANDIDATE_MODE` | `text` | NULL | NULL | 공통env: WS GAP FILL CANDIDATE 모드 (WS_GAP_FILL_CANDIDATE_MODE) |
diff --git a/kis_token_manager.py b/kis_token_manager.py
index 8d9003e..fd57ad1 100644
--- a/kis_token_manager.py
+++ b/kis_token_manager.py
@@ -124,7 +124,7 @@ def token_covers_session(
return exp_dt >= deadline
-def get_token_status(is_mock: bool) -> dict:
+def get_token_status(is_mock: bool, current_app_key: str = None) -> dict:
"""
캐시 파일 상태 반환.
반환: valid=세션커버(ensure 재사용 기준), usable=만료 전 API 사용 가능
@@ -143,6 +143,18 @@ def get_token_status(is_mock: bool) -> dict:
token = cache.get("access_token", "")
expired_s = cache.get("access_token_token_expired", "")
exp_dt = _parse_expired(expired_s)
+ app_key_prefix = cache.get("app_key_prefix", "")
+
+ if current_app_key and app_key_prefix:
+ if not current_app_key.startswith(app_key_prefix):
+ return {
+ "valid": False,
+ "usable": False,
+ "token": "",
+ "expires": "앱키변경됨",
+ "expires_in_h": -999,
+ }
+
if not token or exp_dt is None:
return {
"valid": False,
@@ -299,6 +311,7 @@ def _issue_token(app_key: str, app_secret: str, is_mock: bool) -> bool:
"access_token_token_expired": exp,
"mock": is_mock,
"issued_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
+ "app_key_prefix": app_key[:8] if app_key else "",
}, ensure_ascii=False, indent=2),
encoding="utf-8",
)
@@ -321,7 +334,14 @@ def ensure_token(is_mock: bool, env: dict = None) -> bool:
단일 모드(실전/모의) 토큰: 오늘 세션을 덮으면 재사용, 아니면만 발급.
1일 1회 원칙 — 충분하면 tokenP 호출 없음.
"""
- status = get_token_status(is_mock)
+ if env is None:
+ env = _load_env()
+
+ key_suffix = "MOCK" if is_mock else "REAL"
+ app_key = str(env.get(f"KIS_APP_KEY_{key_suffix}", "") or "").strip()
+ app_secret = str(env.get(f"KIS_APP_SECRET_{key_suffix}", "") or "").strip()
+
+ status = get_token_status(is_mock, current_app_key=app_key)
mode = "모의" if is_mock else "실전"
if status["valid"]:
@@ -351,7 +371,7 @@ def ensure_token(is_mock: bool, env: dict = None) -> bool:
return False
try:
# 잠금 획득 후 다시 확인 (다른 프로세스가 갱신했을 수 있음)
- status = get_token_status(is_mock)
+ status = get_token_status(is_mock, current_app_key=app_key)
if status["valid"]:
logger.info(f"🔑 {mode} 토큰 이미 갱신됨 (다른 프로세스) → 재사용")
return True
@@ -427,6 +447,15 @@ class KisTokenManager:
self._lock = threading.Lock()
self._token: Optional[str] = None
self._expiry: Optional[datetime] = None
+ self._app_key_prefix: Optional[str] = None
+
+ env = _load_env()
+ if env:
+ suffix = "MOCK" if is_mock else "REAL"
+ key = str(env.get(f"KIS_APP_KEY_{suffix}", "")).strip()
+ if key:
+ self._app_key_prefix = key[:8]
+
self._load_from_file() # 재시작 후에도 기존 토큰 재사용
# ── 내부 ──────────────────────────────────────────────────────
@@ -438,6 +467,14 @@ class KisTokenManager:
data = json.loads(self._cache_path.read_text(encoding="utf-8"))
token = data.get("access_token", "")
exp_dt = _parse_expired(data.get("access_token_token_expired", ""))
+
+ # 앱키 변경 감지: 캐시된 app_key_prefix 가 있고, 현재 prefix 와 다르면 무시
+ cached_prefix = data.get("app_key_prefix", "")
+ if self._app_key_prefix and cached_prefix:
+ if self._app_key_prefix != cached_prefix:
+ logger.warning("🔑 [%s] 앱키 변경 감지 → 기존 토큰 캐시 폐기", self._mode_str)
+ return
+
if token and exp_dt:
self._token = token
self._expiry = exp_dt
diff --git a/kis_trader/backtest/optuna_briefing.py b/kis_trader/backtest/optuna_briefing.py
index 7550168..a395c41 100644
--- a/kis_trader/backtest/optuna_briefing.py
+++ b/kis_trader/backtest/optuna_briefing.py
@@ -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]}"
diff --git a/kis_trader/backtest/optuna_common.py b/kis_trader/backtest/optuna_common.py
index 8ae328b..7d0d791 100644
--- a/kis_trader/backtest/optuna_common.py
+++ b/kis_trader/backtest/optuna_common.py
@@ -124,6 +124,14 @@ def annotate_optuna_period_daily_avg(out_data: Optional[Dict[str, Any]]) -> None
out_data["n_trading_days"] = n_days
if out_data.get("min_trades_per_day") is None:
out_data["min_trades_per_day"] = optuna_min_trades_per_day()
+ try:
+ budget = float(
+ out_data.get("total_budget_krw")
+ or out_data.get("total_budget")
+ or 0
+ )
+ except (TypeError, ValueError):
+ budget = 0.0
keys = (
"results", "results_all", "results_gated", "results_stable",
"results_mode", "mode_combo_results",
@@ -141,6 +149,19 @@ def annotate_optuna_period_daily_avg(out_data: Optional[Dict[str, Any]]) -> None
pnl = 0.0
r["n_period_trading_days"] = n_days
r["period_daily_avg_pnl"] = round(pnl / float(n_days), 2)
+ if budget > 0:
+ r["period_daily_avg_pct"] = round(
+ pnl / budget * 100.0 / float(n_days), 3,
+ )
+ elif r.get("daily_avg_pct") is not None:
+ r["period_daily_avg_pct"] = r.get("daily_avg_pct")
+ elif r.get("bot_pct") is not None:
+ try:
+ r["period_daily_avg_pct"] = round(
+ float(r["bot_pct"]) / float(n_days), 3,
+ )
+ except (TypeError, ValueError):
+ pass
def optuna_score_mdd_add() -> float:
@@ -689,6 +710,35 @@ def resolve_results_stable(
return stable, gates
+def resolve_results_mode_consensus(
+ data: Optional[Dict[str, Any]],
+ *,
+ top_n: Optional[int] = None,
+) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
+ """JSON results_mode 우선 · 없으면 mode Top10 즉시 재구성 (구 JSON 호환)."""
+ data = data or {}
+ try:
+ n = int(top_n) if top_n is not None else 10
+ except (TypeError, ValueError):
+ n = 10
+ n = max(1, n)
+ stored = [r for r in list(data.get("results_mode") or []) if isinstance(r, dict)]
+ meta = dict(data.get("mode_consensus_meta") or {})
+ if stored:
+ return stored[:n], meta
+ allr = list(data.get("results_all") or data.get("results") or [])
+ from kis_trader.backtest.optuna_mode_combo import build_results_mode_consensus_tier
+
+ rows, built_meta = build_results_mode_consensus_tier(
+ allr,
+ top_n=n,
+ grid_keys=list(data.get("grid_keys") or []),
+ data=data,
+ )
+ meta.update(built_meta)
+ return rows, meta
+
+
def build_optuna_result_tiers(
rows: List[Dict[str, Any]],
*,
diff --git a/kis_trader/backtest/optuna_mode_combo.py b/kis_trader/backtest/optuna_mode_combo.py
index 8b48a60..585fb40 100644
--- a/kis_trader/backtest/optuna_mode_combo.py
+++ b/kis_trader/backtest/optuna_mode_combo.py
@@ -18,7 +18,7 @@ import logging
from collections import Counter
from typing import Any, Callable, Dict, List, Optional
-from kis_trader.utils.env import get_env_int
+from kis_trader.utils.env import get_env_float, get_env_from_db, get_env_int
from kis_trader.backtest.optuna_tpe_common import finalize_ratchet_combo
logger = logging.getLogger("optuna_mode_combo")
@@ -32,41 +32,92 @@ def resolve_mode_top_n(default: int = 20) -> int:
return max(1, n)
+def resolve_mode_pool_kind() -> str:
+ """
+ mode_combo / mode Top10 / 2차 그리드 밴드 풀.
+ positive(기본)=PnL>0 전체 · gated=results_gated · top_n=Top-N PnL.
+ """
+ raw = str(get_env_from_db("OPTUNA_MODE_POOL", "positive") or "positive").strip().lower()
+ if raw in ("positive", "gated", "top_n"):
+ return raw
+ return "positive"
+
+
+def _valid_pnl_rows(results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
+ return [
+ r for r in (results or [])
+ if r.get("total_pnl") is not None and abs(float(r.get("total_pnl") or 0)) < 1e15
+ ]
+
+
+def select_mode_pool_rows(
+ results: List[Dict[str, Any]],
+ *,
+ data: Optional[Dict[str, Any]] = None,
+ top_n: Optional[int] = None,
+) -> List[Dict[str, Any]]:
+ """mode_combo·밴드·2차 narrow 공통 trial 풀."""
+ kind = resolve_mode_pool_kind()
+ n = int(top_n) if top_n is not None else resolve_mode_top_n(20)
+ rows = _valid_pnl_rows(results)
+ if kind == "gated" and data:
+ gated = [r for r in list(data.get("results_gated") or []) if isinstance(r, dict)]
+ gated = _valid_pnl_rows(gated)
+ if gated:
+ return gated
+ if kind == "positive":
+ pos = [r for r in rows if float(r.get("total_pnl") or 0) > 0]
+ if pos:
+ return pos
+ rows.sort(
+ key=lambda r: (
+ -float(r.get("total_pnl") or 0),
+ -float(r.get("win_rate") or 0),
+ -int(r.get("total_trades") or 0),
+ )
+ )
+ return rows[: max(1, n)]
+ rows.sort(
+ key=lambda r: (
+ -float(r.get("total_pnl") or 0),
+ -float(r.get("win_rate") or 0),
+ -int(r.get("total_trades") or 0),
+ )
+ )
+ return rows[: max(1, n)]
+
+
def mode_combo_from_results(
results: List[Dict[str, Any]],
*,
top_n: int = 20,
grid_keys: Optional[List[str]] = None,
params_key: str = "params",
+ data: Optional[Dict[str, Any]] = None,
+ pool_rows: Optional[List[Dict[str, Any]]] = None,
) -> Dict[str, Any]:
"""
- Top-N(PnL) 축별 최빈 → mode_combo + 빈도 메타.
+ 풀(PnL 양수 전체 등) 축별 최빈 → mode_combo + 빈도 메타.
Returns:
{
"top_n": int,
"pool_size": int,
+ "pool_kind": str,
"params": {축: 최빈값},
"freq": {축: {"value": ..., "count": n, "of": pool}},
"top_pnls": [...],
}
"""
- rows = [
- r for r in (results or [])
- if r.get("total_pnl") is not None and abs(float(r.get("total_pnl") or 0)) < 1e15
- ]
- rows.sort(
- key=lambda r: (
- -float(r.get("total_pnl") or 0),
- -float(r.get("win_rate") or 0),
- -int(r.get("total_trades") or 0),
- )
+ pool_kind = resolve_mode_pool_kind()
+ pool = list(pool_rows) if pool_rows is not None else select_mode_pool_rows(
+ results, data=data, top_n=top_n,
)
- pool = rows[: max(1, int(top_n))]
if not pool:
return {
"top_n": int(top_n),
"pool_size": 0,
+ "pool_kind": pool_kind,
"params": {},
"freq": {},
"top_pnls": [],
@@ -105,6 +156,7 @@ def mode_combo_from_results(
return {
"top_n": int(top_n),
"pool_size": len(pool),
+ "pool_kind": pool_kind,
"params": params,
"freq": freq,
"top_pnls": [float(r.get("total_pnl") or 0) for r in pool[:10]],
@@ -230,6 +282,7 @@ def enrich_out_data_with_mode_combo(
top_n=n,
grid_keys=keys or None,
params_key=params_key,
+ data=out_data,
)
# 래칫 숫자축 최빈 → 엔진용 ratchet_tiers 재조립 (불일치 방지)
strat = str(out_data.get("strategy") or "").strip().lower()
@@ -239,9 +292,10 @@ def enrich_out_data_with_mode_combo(
off_token=off_tok,
)
report: Dict[str, Any] = {
- "method": "top_n_per_axis_mode",
+ "method": "pool_per_axis_mode",
"top_n": mode_meta["top_n"],
"pool_size": mode_meta["pool_size"],
+ "pool_kind": mode_meta.get("pool_kind") or resolve_mode_pool_kind(),
"params": mode_params,
"freq": mode_meta["freq"],
"top_pnls": mode_meta["top_pnls"],
@@ -258,8 +312,8 @@ def enrich_out_data_with_mode_combo(
best_tr = int(res0.get("total_trades") or 0)
lg.info(
- "📊 [mode] Top-%d 최빈 추출 | pool=%d | top_pnls=%s",
- mode_meta["top_n"],
+ "📊 [mode] pool(%s) 최빈 추출 | pool=%d | top_pnls=%s",
+ mode_meta.get("pool_kind") or resolve_mode_pool_kind(),
mode_meta["pool_size"],
mode_meta["top_pnls"][:5],
)
@@ -376,4 +430,257 @@ def enrich_out_data_with_mode_combo(
except Exception as exc2:
lg.warning("⚠️ daily_trail_recommend 폴백 실패: %s", exc2)
+ # mode Top10 — 웹 표·apply source=consensus (구 JSON은 웹에서 재계산)
+ try:
+ from kis_trader.backtest.optuna_postprocess_topn import resolve_post_top_n
+
+ ui_n = resolve_post_top_n(10)
+ mode_rows, mode_meta = build_results_mode_consensus_tier(
+ list(out_data.get("results_all") or out_data.get("results") or []),
+ top_n=ui_n,
+ grid_keys=keys or None,
+ params_key=params_key,
+ data=out_data,
+ )
+ out_data["results_mode"] = mode_rows
+ out_data["mode_consensus_meta"] = mode_meta
+ except Exception as exc:
+ lg.warning("⚠️ results_mode(mode Top10) 첨부 실패: %s", exc)
+
return out_data
+
+
+def _percentile_sorted(sorted_vals: List[float], p: float) -> float:
+ if not sorted_vals:
+ return 0.0
+ if len(sorted_vals) == 1:
+ return sorted_vals[0]
+ idx = (len(sorted_vals) - 1) * p
+ lo = int(idx)
+ hi = min(lo + 1, len(sorted_vals) - 1)
+ w = idx - lo
+ return sorted_vals[lo] * (1.0 - w) + sorted_vals[hi] * w
+
+
+def _row_param_value(row: Dict[str, Any], key: str, *, params_key: str = "params") -> Any:
+ params = row.get(params_key) or row.get("merged_params") or {}
+ if not isinstance(params, dict):
+ params = {}
+ v = params.get(key)
+ if v is None and params_key != "merged_params":
+ v = (row.get("merged_params") or {}).get(key)
+ return v
+
+
+def _coerce_numeric(v: Any) -> Optional[float]:
+ if isinstance(v, bool):
+ return 1.0 if v else 0.0
+ try:
+ return float(v)
+ except (TypeError, ValueError):
+ return None
+
+
+def _build_mode_band_profile(
+ pool: List[Dict[str, Any]],
+ keys: List[str],
+ *,
+ params_key: str = "params",
+) -> Dict[str, Dict[str, Any]]:
+ """
+ Top pool 각 축 — 숫자면 p25~p75 밴드(흔한 구간), 아니면 categorical mode.
+ """
+ profile: Dict[str, Dict[str, Any]] = {}
+ for k in keys:
+ raw_vals: List[Any] = []
+ for row in pool:
+ v = _row_param_value(row, k, params_key=params_key)
+ if v is not None:
+ raw_vals.append(v)
+ if not raw_vals:
+ continue
+ nums: List[float] = []
+ all_numeric = True
+ for v in raw_vals:
+ n = _coerce_numeric(v)
+ if n is None:
+ all_numeric = False
+ break
+ nums.append(n)
+ if all_numeric and nums:
+ s = sorted(nums)
+ p25 = _percentile_sorted(s, 0.25)
+ p50 = _percentile_sorted(s, 0.50)
+ p75 = _percentile_sorted(s, 0.75)
+ iqr = max(p75 - p25, abs(p50) * 0.05, 1e-9)
+ profile[k] = {
+ "kind": "numeric",
+ "p25": p25,
+ "p50": p50,
+ "p75": p75,
+ "iqr": iqr,
+ }
+ else:
+ c: Counter = Counter(str(v) for v in raw_vals)
+ mode_s, _cnt = c.most_common(1)[0]
+ sample = next(v for v in raw_vals if str(v) == mode_s)
+ profile[k] = {"kind": "categorical", "mode": sample}
+ return profile
+
+
+def _trial_band_proximity(
+ row: Dict[str, Any],
+ profile: Dict[str, Dict[str, Any]],
+ *,
+ params_key: str = "params",
+) -> Dict[str, Any]:
+ """
+ trial ↔ pool 흔한 구간(p25~p75) 근접도. 100%=모든 축이 밴드 안 또는 매우 가까움.
+ (구: 축값 완전 일치 개수 — tp 22% vs 4% 뒤섞임 원인)
+ """
+ decay_iqr = max(0.1, float(get_env_float("OPTUNA_MODE_BAND_DECAY_IQR", 1.5)))
+ scores: List[float] = []
+ in_band = 0
+ total = 0
+ err_sum = 0.0
+ for k, band in profile.items():
+ rv = _row_param_value(row, k, params_key=params_key)
+ if rv is None:
+ continue
+ total += 1
+ if band.get("kind") == "numeric":
+ nv = _coerce_numeric(rv)
+ if nv is None:
+ mode_v = band.get("mode")
+ if mode_v is not None:
+ axis_s = 1.0 if str(rv) == str(mode_v) else 0.0
+ else:
+ axis_s = 0.0
+ if axis_s >= 1.0:
+ in_band += 1
+ else:
+ err_sum += 1.0
+ scores.append(axis_s)
+ continue
+ p25 = float(band["p25"])
+ p50 = float(band["p50"])
+ p75 = float(band["p75"])
+ iqr = float(band["iqr"])
+ if p25 <= nv <= p75:
+ axis_s = 1.0
+ in_band += 1
+ err_sum += 0.0
+ else:
+ dist = (p25 - nv) if nv < p25 else (nv - p75)
+ axis_s = max(0.0, 1.0 - dist / (iqr * decay_iqr))
+ err_sum += dist / iqr
+ else:
+ mode_v = band.get("mode")
+ axis_s = 1.0 if str(rv) == str(mode_v) else 0.0
+ if axis_s >= 1.0:
+ in_band += 1
+ err_sum += 0.0 if axis_s >= 1.0 else 1.0
+ scores.append(axis_s)
+ pct = (sum(scores) / float(len(scores)) * 100.0) if scores else 0.0
+ mean_err = (err_sum / float(total)) if total else 0.0
+ return {
+ "matched": in_band,
+ "total": total,
+ "pct": round(pct, 1),
+ "mean_band_err": round(mean_err, 4),
+ }
+
+
+def _trial_consensus_match(
+ row: Dict[str, Any],
+ mode_params: Dict[str, Any],
+ *,
+ params_key: str = "params",
+) -> Dict[str, Any]:
+ """trial params 가 mode(축별 최빈) 와 몇 축 일치하는지."""
+ params = row.get(params_key) or row.get("merged_params") or {}
+ if not isinstance(params, dict):
+ params = {}
+ matched = 0
+ total = 0
+ for k, mv in (mode_params or {}).items():
+ rv = params.get(k)
+ if rv is None and params_key != "merged_params":
+ rv = (row.get("merged_params") or {}).get(k)
+ if rv is None:
+ continue
+ total += 1
+ if str(rv) == str(mv):
+ matched += 1
+ pct = (float(matched) / float(total) * 100.0) if total else 0.0
+ return {"matched": matched, "total": total, "pct": round(pct, 1)}
+
+
+def build_results_mode_consensus_tier(
+ results: List[Dict[str, Any]],
+ *,
+ top_n: int = 10,
+ mode_top_n: Optional[int] = None,
+ grid_keys: Optional[List[str]] = None,
+ params_key: str = "params",
+ data: Optional[Dict[str, Any]] = None,
+) -> tuple[List[Dict[str, Any]], Dict[str, Any]]:
+ """
+ mode Top10 — 풀(PnL 양수 전체 등) p25~p75 밴드에 **가장 가까운** trial 순.
+
+ 밴드는 pool 전체에서 계산 · 후보는 pool 전 trial(양수 전체)에서 근접도 순.
+ mode_combo(1회 실측·trial 없음)와 달리 **실제 trial 번호**가 있음.
+ """
+ mode_n = int(mode_top_n) if mode_top_n is not None else resolve_mode_top_n(20)
+ pool_kind = resolve_mode_pool_kind()
+ learn_pool = select_mode_pool_rows(list(results or []), data=data, top_n=mode_n)
+ mode_meta = mode_combo_from_results(
+ list(results or []),
+ top_n=mode_n,
+ grid_keys=grid_keys,
+ params_key=params_key,
+ pool_rows=learn_pool,
+ )
+ meta: Dict[str, Any] = {
+ "mode_top_n": mode_n,
+ "mode_pool_size": int(mode_meta.get("pool_size") or 0),
+ "mode_pool_kind": pool_kind,
+ "mode_params_keys": len(mode_meta.get("params") or {}),
+ "scoring": "band_proximity_p25_p75",
+ "note": f"pool={pool_kind} · 축별 p25~p75 밴드 근접도(오차↓) · OPTUNA_MODE_POOL",
+ }
+ if not mode_meta.get("pool_size"):
+ meta["note"] = "mode pool 없음 — results·grid_keys 확인"
+ return [], meta
+
+ candidate_pool = list(learn_pool)
+ keys: List[str] = []
+ if grid_keys:
+ keys = [k for k in grid_keys if k]
+ if not keys:
+ seen: set = set()
+ for r in learn_pool:
+ for k in (r.get(params_key) or {}).keys():
+ if k not in seen:
+ seen.add(k)
+ keys.append(k)
+ profile = _build_mode_band_profile(learn_pool, keys, params_key=params_key)
+ meta["band_axes"] = len(profile)
+ scored: List[Dict[str, Any]] = []
+ for r in candidate_pool:
+ m = _trial_band_proximity(r, profile, params_key=params_key)
+ row = dict(r)
+ row["consensus_match_pct"] = m["pct"]
+ row["consensus_match_n"] = m["matched"]
+ row["consensus_match_of"] = m["total"]
+ row["consensus_band_err"] = m.get("mean_band_err")
+ scored.append(row)
+ scored.sort(
+ key=lambda r: (
+ -float(r.get("consensus_match_pct") or 0),
+ float(r.get("consensus_band_err") or 999.0),
+ -float(r.get("total_pnl") or 0),
+ -float(r.get("score") or 0),
+ )
+ )
+ return scored[: max(1, int(top_n))], meta
diff --git a/kis_trader/backtest/optuna_postprocess_topn.py b/kis_trader/backtest/optuna_postprocess_topn.py
index edd38a1..8ab8536 100644
--- a/kis_trader/backtest/optuna_postprocess_topn.py
+++ b/kis_trader/backtest/optuna_postprocess_topn.py
@@ -785,7 +785,7 @@ def append_learn_postprocess_anchors(
(data or {}).get("results")
or (data or {}).get("results_all")
or []
- )[: max(1, int(top_n or 5))]
+ )[: max(1, int(top_n or 10))]
if not learn:
return
lg.info(
@@ -856,7 +856,7 @@ def append_stable_postprocess_anchors(
if any(str(a.get("role") or "") == "stable" for a in anchors):
return
from kis_trader.backtest.optuna_common import resolve_results_stable
- stable, _meta = resolve_results_stable(data, top_n=max(1, int(top_n or 5)))
+ stable, _meta = resolve_results_stable(data, top_n=max(1, int(top_n or 10)))
# 후처리 중 JSON에 비어 있으면 재구성분 반영 (다음 요약·앵커 일치)
if not list((data or {}).get("results_stable") or []) and stable:
data["results_stable"] = list(stable)
@@ -976,7 +976,7 @@ def attach_topn_postprocess(
stable_preview: List[Any] = []
if _include_stable():
from kis_trader.backtest.optuna_common import resolve_results_stable
- stable_preview, _sg = resolve_results_stable(data, top_n=max(1, int(top_n or 5)))
+ stable_preview, _sg = resolve_results_stable(data, top_n=max(1, int(top_n or 10)))
if not list((data or {}).get("results_stable") or []) and stable_preview:
data["results_stable"] = list(stable_preview)
if _sg:
@@ -985,7 +985,7 @@ def attach_topn_postprocess(
if not gated:
learn_preview = list(
(data or {}).get("results") or (data or {}).get("results_all") or []
- )[: max(1, int(top_n or 5))]
+ )[: max(1, int(top_n or 10))]
n_units = len(gated) + len(learn_preview) + (len(stable_preview) if _include_stable() else 0)
if _include_mode():
n_units += 1
diff --git a/kis_trader/backtest/optuna_search_space.py b/kis_trader/backtest/optuna_search_space.py
index 2f963d5..b4fdd2d 100644
--- a/kis_trader/backtest/optuna_search_space.py
+++ b/kis_trader/backtest/optuna_search_space.py
@@ -32,11 +32,17 @@ def _dedupe_preserve_order(values: List[Any]) -> List[Any]:
def _suggest_from_grid(trial: optuna.Trial, grid: Dict[str, List[Any]]) -> Dict[str, Any]:
+ from kis_trader.backtest.optuna_grid_narrow import load_narrow_grid_override
+
+ narrow = load_narrow_grid_override()
combo: Dict[str, Any] = {}
for key, values in grid.items():
if not values:
continue
- choices = _dedupe_preserve_order(list(values))
+ src = narrow.get(key) if narrow.get(key) else values
+ choices = _dedupe_preserve_order(list(src))
+ if not choices:
+ continue
combo[key] = trial.suggest_categorical(key, choices)
return combo
diff --git a/kis_trader/backtest/optuna_study_store.py b/kis_trader/backtest/optuna_study_store.py
index fa7a684..d44dfa1 100644
--- a/kis_trader/backtest/optuna_study_store.py
+++ b/kis_trader/backtest/optuna_study_store.py
@@ -298,6 +298,46 @@ def load_payload_dict(study_name: str) -> Optional[Dict[str, Any]]:
return None
+def load_phase1_study_for_job(job_id: str) -> Optional[str]:
+ """step job_id → 1차 study_name (MariaDB). refine1 우선."""
+ jid = str(job_id or "").strip()[:64]
+ if not jid:
+ return None
+ try:
+ ensure_optuna_study_result_table()
+ cur = _db().conn.execute(
+ "SELECT study_name FROM optuna_study_result "
+ "WHERE job_id=%s AND study_name LIKE %s "
+ "ORDER BY updated_at DESC LIMIT 1",
+ (jid, "%refine1%"),
+ )
+ row = cur.fetchone()
+ if row:
+ name = str(row.get("study_name") or "").strip()
+ if name:
+ return name
+ cur = _db().conn.execute(
+ "SELECT study_name FROM optuna_study_result "
+ "WHERE job_id=%s ORDER BY updated_at DESC LIMIT 1",
+ (jid,),
+ )
+ row = cur.fetchone()
+ if row:
+ return str(row.get("study_name") or "").strip() or None
+ except Exception as exc:
+ logger.warning("⚠️ load_phase1_study_for_job 실패 job=%s: %s", jid, exc)
+ return None
+
+
+def phase1_payload_ready(study_name: str) -> bool:
+ """1차 Top10 narrow 입력용 payload 가 DB에 있는지."""
+ name = str(study_name or "").strip()
+ if not name:
+ return False
+ data = load_payload_dict(name)
+ return payload_has_rows(data)
+
+
def upsert_counts(
*,
study_name: str,
diff --git a/kis_trader/backtest/optuna_web_jobs.py b/kis_trader/backtest/optuna_web_jobs.py
index 7aa4876..70b8dbc 100644
--- a/kis_trader/backtest/optuna_web_jobs.py
+++ b/kis_trader/backtest/optuna_web_jobs.py
@@ -20,7 +20,7 @@ import threading
import time
from datetime import datetime
from pathlib import Path
-from typing import Any, Dict, List, Optional
+from typing import Any, Dict, List, Optional, Tuple
ROOT = Path(__file__).resolve().parents[2]
JOBS_DIR = ROOT / "logs" / "optuna_web_jobs"
@@ -122,6 +122,31 @@ def _result_study_name(meta: Optional[Dict[str, Any]]) -> str:
def _attach_study_result_flags(m: Dict[str, Any]) -> None:
+ kind = str(m.get("kind") or "")
+ st = str(m.get("status") or "").strip().lower()
+ rs = m.get("refine_state") if isinstance(m.get("refine_state"), dict) else {}
+ prog = m.get("progress") if isinstance(m.get("progress"), dict) else {}
+ refine_phase = str(rs.get("phase") or prog.get("refine_phase") or "").strip().lower()
+ # 1·2차 refine 진행 중 — 1차 study 「목표 도달」을 상태에 붙이지 않음
+ if kind == "mode_refine" and st == "running" and refine_phase in ("phase1", "phase2"):
+ done = prog.get("trials_done")
+ tot = prog.get("trials_total") or m.get("trials")
+ tag = "1차" if refine_phase == "phase1" else "2차"
+ if done is not None and tot:
+ m["leftover_note"] = f"{tag} TPE 진행 {done}/{tot}"
+ else:
+ m["leftover_note"] = f"{tag} TPE 진행 중"
+ m["can_continue"] = False
+ m["can_confirm"] = False
+ if refine_phase == "phase2":
+ try:
+ st_goal = int(m.get("study_trials") or 0)
+ except (TypeError, ValueError):
+ st_goal = 0
+ if st_goal > 0 and done is not None:
+ m["n_complete"] = int(done)
+ m["study_trials"] = st_goal
+ return
name = _result_study_name(m)
if not name:
m["can_continue"] = False
@@ -139,7 +164,7 @@ def _attach_study_result_flags(m: Dict[str, Any]) -> None:
m["leftover_note"] = fl.get("leftover_note") or ""
st = str(m.get("status") or "").strip().lower()
leftover_ok = st == "done" and int(m.get("leftover_trials") or 0) > 0
- seq = str(m.get("kind") or "") in ("seq", "seq4")
+ seq = str(m.get("kind") or "") in ("seq", "seq4", "mode_refine")
m["can_continue"] = leftover_ok and not seq
m["can_confirm"] = leftover_ok and not seq
@@ -337,6 +362,34 @@ def _job_sort_ts(meta: Dict[str, Any], sort: str = "started") -> float:
return st
+def filter_redundant_refine1_jobs(jobs: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
+ """2차 refine import 가 있으면 동기간 1차 import 는 목록에서 숨김 (중간산출)."""
+ refine2_keys: set = set()
+ for j in jobs:
+ sn = str(j.get("study_name") or "")
+ if "refine2" in sn:
+ refine2_keys.add((
+ str(j.get("strategy") or ""),
+ str(j.get("start") or "")[:10],
+ str(j.get("end") or "")[:10],
+ ))
+ if not refine2_keys:
+ return jobs
+ out: List[Dict[str, Any]] = []
+ for j in jobs:
+ sn = str(j.get("study_name") or "")
+ if "refine1" in sn and str(j.get("kind") or "") == "import":
+ key = (
+ str(j.get("strategy") or ""),
+ str(j.get("start") or "")[:10],
+ str(j.get("end") or "")[:10],
+ )
+ if key in refine2_keys:
+ continue
+ out.append(j)
+ return out
+
+
def list_jobs(limit: int = 30, sort: str = "started") -> List[Dict[str, Any]]:
_ensure_dirs()
out: List[Dict[str, Any]] = []
@@ -346,6 +399,7 @@ def list_jobs(limit: int = 30, sort: str = "started") -> List[Dict[str, Any]]:
except Exception:
continue
out.sort(key=lambda m: _job_sort_ts(m, sort), reverse=True)
+ out = filter_redundant_refine1_jobs(out)
return out[: max(1, int(limit))]
@@ -353,6 +407,11 @@ def job_list_row(meta: Dict[str, Any]) -> Dict[str, Any]:
"""잡 목록 테이블용 슬림 행 — 후처리 JSON·브리핑·로그테일 제외."""
m = dict(meta or {})
_attach_study_result_flags(m)
+ if not isinstance(m.get("period_info"), dict):
+ try:
+ m["period_info"] = _build_period_info(m)
+ except Exception:
+ pass
prog = m.get("progress") if isinstance(m.get("progress"), dict) else {}
post = m.get("postprocess") if isinstance(m.get("postprocess"), dict) else {}
return {
@@ -378,15 +437,28 @@ def job_list_row(meta: Dict[str, Any]) -> Dict[str, Any]:
"trials_done": prog.get("trials_done"),
"trials_total": prog.get("trials_total"),
"pct": prog.get("pct"),
+ "refine_phase": prog.get("refine_phase"),
+ "label": prog.get("label"),
},
"postprocess": {
"pct": post.get("pct"),
"ready": post.get("ready"),
},
"join_cmd": m.get("join_cmd") or "",
+ "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 "",
"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 [],
+ "seq_refine_cmds": m.get("seq_refine_cmds") or [],
+ "period_info": m.get("period_info") if isinstance(m.get("period_info"), dict) else None,
+ "kind": m.get("kind"),
+ "study_name": m.get("study_name") or m.get("active_study_name") or "",
+ "study_short": m.get("study_short") or _study_short_note(
+ str(m.get("study_name") or m.get("active_study_name") or "")
+ ),
+ "source": m.get("source"),
}
@@ -666,7 +738,7 @@ def _read_seq_active_sidecar(path: Optional[str]) -> Dict[str, str]:
k, _, v = line.partition("=")
k = k.strip().lower()
v = v.strip()
- if k in ("strategy", "study", "entry_mode", "sl_mode", "extra"):
+ if k in ("strategy", "study", "entry_mode", "sl_mode", "ob_mode", "extra", "refine_state_path"):
out[k] = v
except Exception:
return {}
@@ -838,6 +910,414 @@ def _ps_join_script(argv: List[str]) -> str:
)
+def _refine_runner_argv(
+ meta: Dict[str, Any],
+ *,
+ strategy: str,
+ job_id: str,
+ entry_mode: Optional[str] = None,
+ sl_mode: Optional[str] = None,
+ ob_mode: Optional[str] = None,
+ skip_phase1: bool = False,
+ phase1_json: Optional[str] = None,
+ phase1_study: Optional[str] = None,
+ py_bin: str = ".venv/bin/python",
+) -> List[str]:
+ """다른 PC — optuna_mode_refine_runner.py (1·2차 연쇄). 상대경로."""
+ from kis_trader.backtest.optuna_common import (
+ normalize_optuna_sort_by,
+ resolve_optuna_min_trades,
+ )
+
+ strat = str(strategy or "").strip().lower()
+ mode = str(meta.get("mode") or "tpe").strip() or "tpe"
+ start = str(meta.get("start") or "")
+ end = str(meta.get("end") or "")
+ trials = str(int(meta.get("trials") or 200))
+ hist = str(meta.get("universe_history_source") or "kiwoom").strip() or "kiwoom"
+ sort_by = normalize_optuna_sort_by(meta.get("sort_by") or "score", web=True)
+ _mt = resolve_optuna_min_trades(start, end, strat)
+ argv = [
+ py_bin, "-u", "kis_trader/backtest/optuna_mode_refine_runner.py",
+ "--job-id", str(job_id),
+ "--strategy", strat,
+ "--mode", mode,
+ "--start", start,
+ "--end", end,
+ "--trials", trials,
+ "--sort-by", sort_by,
+ "--min-trades", str(int(_mt["min_trades"])),
+ "--universe-history-source", hist,
+ ]
+ try:
+ st_goal = int(meta.get("study_trials") or 0)
+ except (TypeError, ValueError):
+ st_goal = 0
+ if st_goal > 0:
+ argv.extend(["--study-trials", str(st_goal)])
+ if strat == "tail" and entry_mode:
+ argv.extend(["--entry-mode", str(entry_mode)])
+ if strat == "breakout":
+ argv.extend([
+ "--sl-mode", str(sl_mode or "fixed"),
+ "--ob-mode", str(ob_mode or "off"),
+ ])
+ sym = str(meta.get("symbol") or "").strip().upper()
+ if sym and strat == "us_momentum":
+ argv.extend(["--symbol", sym])
+ for flag, key in (
+ ("--candle-source", "candle_source"),
+ ("--tick-source", "tick_source"),
+ ("--ob-source", "ob_source"),
+ ):
+ val = str(meta.get(key) or "").strip()
+ if val:
+ argv.extend([flag, val])
+ if skip_phase1:
+ if phase1_study:
+ argv.extend(["--skip-phase1", "--phase1-study", str(phase1_study)])
+ elif phase1_json:
+ argv.extend(["--skip-phase1", "--phase1-json", str(phase1_json)])
+ return argv
+
+
+def _seq_env_export_lines(meta: Dict[str, Any]) -> List[str]:
+ """순차 bash 재현용 env (레포 루트 기준)."""
+ picked = list(meta.get("strategies") or [])
+ if not picked:
+ raw = str(meta.get("strategy") or "")
+ picked = [s.strip() for s in raw.split(",") if s.strip() and s.strip() not in ("seq", "all")]
+ tail_ems = list(meta.get("tail_entry_modes") or ["align"])
+ bo_sms = list(meta.get("breakout_sl_modes") or ["fixed"])
+ bo_oms = list(meta.get("breakout_ob_modes") or ["off"])
+ pairs: List[Tuple[str, str]] = [
+ ("START", str(meta.get("start") or "")),
+ ("END", str(meta.get("end") or "")),
+ ("TRIALS", str(int(meta.get("trials") or 200))),
+ ("MODE", str(meta.get("mode") or "tpe")),
+ ("SORT_BY", str(meta.get("sort_by") or "score")),
+ ("UNIVERSE_HISTORY_SOURCE", str(meta.get("universe_history_source") or "kiwoom")),
+ ("STRATEGIES", " ".join(picked)),
+ ("TAIL_OPTUNA_ENTRY_MODES", " ".join(tail_ems)),
+ ("BREAKOUT_OPTUNA_SL_MODES", " ".join(bo_sms)),
+ ("BREAKOUT_OPTUNA_OB_MODES", " ".join(bo_oms)),
+ ]
+ try:
+ st = int(meta.get("study_trials") or 0)
+ except (TypeError, ValueError):
+ st = 0
+ if st > 0:
+ pairs.append(("STUDY_TRIALS", str(st)))
+ jid = str(meta.get("job_id") or "").strip()
+ if jid:
+ pairs.append(("OPTUNA_SEQ_JOB_ID", jid))
+ for ek, mk in (
+ ("CANDLE_SOURCE", "candle_source"),
+ ("TICK_SOURCE", "tick_source"),
+ ("OB_SOURCE", "ob_source"),
+ ):
+ v = str(meta.get(mk) or "").strip()
+ if v:
+ pairs.append((ek, v))
+ lines: List[str] = []
+ for k, v in pairs:
+ if v:
+ lines.append(f"export {k}={shlex.quote(v)}")
+ return lines
+
+
+def _build_web_cmd_full(meta: Dict[str, Any]) -> str:
+ kind = str(meta.get("kind") or "")
+ if kind == "mode_refine":
+ strat = str(meta.get("strategy") or "").split(",")[0].strip().lower()
+ jid = str(meta.get("job_id") or "manual_refine")
+ tail_ems = list(meta.get("tail_entry_modes") or [])
+ bo_sms = list(meta.get("breakout_sl_modes") or ["fixed"])
+ bo_oms = list(meta.get("breakout_ob_modes") or ["off"])
+ em = tail_ems[0] if strat == "tail" and tail_ems else None
+ sm = bo_sms[0] if strat == "breakout" else None
+ om = bo_oms[0] if strat == "breakout" else None
+ argv = _refine_runner_argv(
+ meta, strategy=strat, job_id=jid,
+ entry_mode=em, sl_mode=sm, ob_mode=om,
+ py_bin=".venv/bin/python",
+ )
+ return "# 레포 루트 (1·2차 단일)\n" + _quote_cmd(argv)
+ if kind not in ("seq", "seq4"):
+ web_argv = meta.get("cmd_argv")
+ if isinstance(web_argv, list) and web_argv:
+ return "# 레포 루트\n" + _quote_cmd([str(x) for x in web_argv])
+ return str(meta.get("cmd") or "").strip()
+ env_lines = _seq_env_export_lines(meta)
+ body = "\n".join(env_lines) + "\nbash scripts/run_optuna_4strat_tpe_seq.sh"
+ return "# 레포 루트 (순차 1·2차 전체 — 이 VM과 동일 설정)\n" + body
+
+
+def _parse_seq_phase1_studies_from_log(log_path: str) -> List[str]:
+ """마스터/refine 로그 순서대로 OPTUNA_PHASE1_STUDY= 수집."""
+ if not log_path or not Path(log_path).is_file():
+ return []
+ try:
+ data = Path(log_path).read_bytes()
+ if len(data) > 800_000:
+ data = data[-800_000:]
+ text = data.decode("utf-8", errors="replace")
+ except Exception:
+ return []
+ out: List[str] = []
+ seen: set = set()
+ for m in re.finditer(r"OPTUNA_PHASE1_STUDY=(\S+)", text):
+ sy = str(m.group(1) or "").strip()
+ if sy and sy not in seen:
+ seen.add(sy)
+ out.append(sy)
+ return out
+
+
+def _parse_seq_refine_states_from_log(log_path: str) -> List[Dict[str, Any]]:
+ """마스터 로그 STATE= 경로 순서 → refine_state 내용."""
+ if not log_path or not Path(log_path).is_file():
+ return []
+ try:
+ data = Path(log_path).read_bytes()
+ if len(data) > 800_000:
+ data = data[-800_000:]
+ text = data.decode("utf-8", errors="replace")
+ except Exception:
+ return []
+ out: List[Dict[str, Any]] = []
+ seen: set = set()
+ for m in re.finditer(r"STATE=(\S+refine_state\.json)", text):
+ sp = str(m.group(1) or "").strip()
+ if not sp or sp in seen:
+ continue
+ seen.add(sp)
+ p = Path(sp)
+ if not p.is_file():
+ p = ROOT / sp
+ row: Dict[str, Any] = {"state_path": str(p)}
+ if p.is_file():
+ try:
+ st = json.loads(p.read_text(encoding="utf-8"))
+ if isinstance(st, dict):
+ row.update(st)
+ except Exception:
+ pass
+ out.append(row)
+ return out
+
+
+def _resolve_step_phase1(
+ meta: Dict[str, Any],
+ *,
+ step_job_id: str,
+ step_i: int,
+ state_row: Optional[Dict[str, Any]] = None,
+ log_phase1_studies: Optional[List[str]] = None,
+ log_result_jsons: Optional[List[str]] = None,
+) -> Dict[str, Any]:
+ """1차 study/JSON — DB·state·로그·job_id 조회."""
+ from kis_trader.backtest.optuna_study_store import (
+ load_phase1_study_for_job,
+ phase1_payload_ready,
+ )
+
+ st = state_row if isinstance(state_row, dict) else {}
+ p1_study = str(st.get("phase1_study") or "").strip()
+ p1_json = str(st.get("phase1_json") or "").strip()
+ idx = max(0, int(step_i) - 1)
+ log_studies = list(log_phase1_studies or [])
+ log_jsons = list(log_result_jsons or [])
+ if not p1_study and idx < len(log_studies):
+ p1_study = str(log_studies[idx] or "").strip()
+ if not p1_study and p1_json:
+ p1_study = str(_phase1_study_from_result_json(p1_json) or "").strip()
+ if not p1_study:
+ p1_study = str(load_phase1_study_for_job(step_job_id) or "").strip()
+ if not p1_json and idx < len(log_jsons):
+ p1_json = str(log_jsons[idx] or "").strip()
+ saved = list(meta.get("seq_refine_steps") or [])
+ if not p1_study and idx < len(saved):
+ p1_study = str((saved[idx] or {}).get("phase1_study") or "").strip()
+ db_ok = bool(p1_study and phase1_payload_ready(p1_study))
+ file_ok = bool(p1_json and Path(p1_json).is_file())
+ return {
+ "phase1_study": p1_study or None,
+ "phase1_json": p1_json or None,
+ "phase1_db": db_ok,
+ "done": db_ok or file_ok,
+ }
+
+
+def _parse_seq_result_jsons_from_log(log_path: str) -> List[str]:
+ if not log_path or not Path(log_path).is_file():
+ return []
+ try:
+ data = Path(log_path).read_bytes()
+ if len(data) > 800_000:
+ data = data[-800_000:]
+ text = data.decode("utf-8", errors="replace")
+ except Exception:
+ return []
+ out: List[str] = []
+ for m in re.finditer(r"OPTUNA_RESULT_JSON=(\S+)", text):
+ p = str(m.group(1) or "").strip()
+ if not p:
+ continue
+ if out and out[-1] == p:
+ continue
+ out.append(p)
+ return out
+
+
+def _phase1_study_from_result_json(json_path: str) -> Optional[str]:
+ """2차 결과 JSON → 1차 study_name (refine2→refine1 치환)."""
+ path = str(json_path or "").strip()
+ if not path or not Path(path).is_file():
+ return None
+ try:
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
+ except Exception:
+ return None
+ if not isinstance(data, dict):
+ return None
+ sn = str(data.get("optuna_study_name") or data.get("study_name") or "").strip()
+ if not sn:
+ return None
+ if "refine2" in sn:
+ return sn.replace("refine2", "refine1", 1)
+ if "refine1" in sn:
+ return sn
+ return None
+
+
+def _step_refine_params(meta: Dict[str, Any], row: Dict[str, str]) -> Tuple[str, Optional[str], Optional[str], Optional[str]]:
+ strat = str(row.get("strategy") or "").strip().lower()
+ extra = str(row.get("extra") or "").strip()
+ em = sm = om = None
+ if strat == "tail":
+ em = extra or "align"
+ elif strat == "breakout":
+ if extra:
+ sm, om = _parse_breakout_seq_extra(extra)
+ else:
+ sms = list(meta.get("breakout_sl_modes") or ["fixed"])
+ oms = list(meta.get("breakout_ob_modes") or ["off"])
+ sm = str(sms[0] if sms else "fixed")
+ om = str(oms[0] if oms else "off")
+ return strat, em, sm, om
+
+
+def _build_seq_refine_cmds(meta: Dict[str, Any]) -> List[Dict[str, Any]]:
+ """순차·단일 1·2차 — PC별 병렬용 refine runner 명령 목록."""
+ kind = str(meta.get("kind") or "")
+ catalog = _seq_step_catalog(meta)
+ if kind == "mode_refine" and not catalog:
+ strat = str(meta.get("strategy") or "").split(",")[0].strip().lower()
+ if strat:
+ catalog = [{"strategy": strat, "extra": ""}]
+ if strat == "tail":
+ ems = list(meta.get("tail_entry_modes") or ["align"])
+ catalog = [{"strategy": "tail", "extra": str(ems[0] if ems else "align")}]
+ elif strat == "breakout":
+ from kis_trader.backtest.optuna_breakout_tpe_space import breakout_tpe_study_extra
+ sms = list(meta.get("breakout_sl_modes") or ["fixed"])
+ oms = list(meta.get("breakout_ob_modes") or ["off"])
+ catalog = [{
+ "strategy": "breakout",
+ "extra": breakout_tpe_study_extra(sms[0], oms[0]),
+ }]
+ if not catalog:
+ return []
+ base_jid = str(meta.get("job_id") or "manual").replace(" ", "_")
+ log_path = str(meta.get("log_path") or "")
+ done_jsons = _parse_seq_result_jsons_from_log(log_path)
+ log_p1_studies = _parse_seq_phase1_studies_from_log(log_path)
+ state_rows = _parse_seq_refine_states_from_log(log_path)
+ rows: List[Dict[str, Any]] = []
+ for i, row in enumerate(catalog, start=1):
+ strat, em, sm, om = _step_refine_params(meta, row)
+ extra = str(row.get("extra") or "")
+ lab_parts = [strat]
+ if extra:
+ lab_parts.append(extra)
+ label = "/".join(lab_parts)
+ slug = re.sub(r"[^a-z0-9_]+", "_", f"{strat}_{extra or 'base'}").strip("_")[:32]
+ step_jid = f"{base_jid}_{slug}_{i}"
+ state_row = state_rows[i - 1] if i - 1 < len(state_rows) else None
+ p1 = _resolve_step_phase1(
+ meta,
+ step_job_id=step_jid,
+ step_i=i,
+ state_row=state_row,
+ log_phase1_studies=log_p1_studies,
+ log_result_jsons=done_jsons,
+ )
+ p1_study = str(p1.get("phase1_study") or "").strip()
+ p1_json = str(p1.get("phase1_json") or "").strip()
+ p1_db = bool(p1.get("phase1_db"))
+ p1_done = bool(p1.get("done"))
+ argv_full = _refine_runner_argv(
+ meta, strategy=strat, job_id=step_jid,
+ entry_mode=em, sl_mode=sm, ob_mode=om,
+ py_bin=".venv/bin/python",
+ )
+ argv_ps = _refine_runner_argv(
+ meta, strategy=strat, job_id=step_jid,
+ entry_mode=em, sl_mode=sm, ob_mode=om,
+ py_bin="python",
+ )
+ phase2_note = ""
+ cmd_p2 = ""
+ cmd_p2_ps = ""
+ if p1_study and p1_db:
+ phase2_note = f"1차 DB OK · --phase1-study {p1_study}"
+ elif p1_study:
+ phase2_note = f"1차 study · --phase1-study {p1_study} (DB payload 확인)"
+ elif p1_json and Path(p1_json).is_file():
+ phase2_note = f"1차 JSON(로컬): {p1_json}"
+ else:
+ phase2_note = (
+ "1차 완료 후 --phase1-study (MariaDB payload · DB_HOST=141 공유)"
+ )
+ p1_study = "PHASE1_STUDY_NAME_HERE"
+ argv_p2 = _refine_runner_argv(
+ meta, strategy=strat, job_id=step_jid + "_p2only",
+ entry_mode=em, sl_mode=sm, ob_mode=om,
+ skip_phase1=True,
+ phase1_study=p1_study if p1_study else None,
+ phase1_json=p1_json if (not p1_study and p1_json) else None,
+ py_bin=".venv/bin/python",
+ )
+ argv_p2_ps = _refine_runner_argv(
+ meta, strategy=strat, job_id=step_jid + "_p2only",
+ entry_mode=em, sl_mode=sm, ob_mode=om,
+ skip_phase1=True,
+ phase1_study=p1_study if p1_study else None,
+ phase1_json=p1_json if (not p1_study and p1_json) else None,
+ py_bin="python",
+ )
+ cmd_p2 = _quote_cmd(argv_p2)
+ cmd_p2_ps = _ps_join_script(argv_p2_ps)
+ rows.append({
+ "step": i,
+ "label": label,
+ "strategy": strat,
+ "extra": extra,
+ "job_id": step_jid,
+ "cmd": _quote_cmd(argv_full),
+ "cmd_ps": _ps_join_script(argv_ps),
+ "cmd_phase2": cmd_p2,
+ "cmd_phase2_ps": cmd_p2_ps,
+ "phase1_study": p1_study or None,
+ "phase1_json": p1_json or None,
+ "phase1_db": p1_db,
+ "phase2_note": phase2_note,
+ "done": p1_done,
+ })
+ return rows
+
+
def build_optuna_join_payload(meta: Dict[str, Any]) -> Dict[str, Any]:
"""웹 실행 명령 + 다른 PC에서 같은 study 에 붙는 python 명령."""
m = meta or {}
@@ -864,15 +1344,15 @@ def build_optuna_join_payload(meta: Dict[str, Any]) -> Dict[str, Any]:
web_cmd = _quote_cmd([str(x) for x in web_argv])
else:
web_cmd = str(m.get("cmd") or "").strip()
+ web_cmd_full = _build_web_cmd_full(m)
+ seq_refine_cmds = _build_seq_refine_cmds(m)
hints = [
- "레포 루트 · 웹과 같은 git 커밋 · MariaDB 141/kis_optuna.",
- "PowerShell: .\\.venv\\Scripts\\python.exe 사용. --trials=이 PC 추가분(남은 횟수), --study-trials=스터디 총 목표.",
- "목표가 이미 찼으면 --trials 0 (추가 연타 없음). 자잘한 VM 다수보다 Win PC 1대가 현실적.",
+ "레포 루트 · git 커밋 동일 · MariaDB(kis_optuna) 공유 필수.",
+ "병렬: 「전략별 1·2차」를 PC마다 1줄씩 — RAM 분산. study 이름은 runner가 PC마다 새로 붙임(각 PC 독립 1→2).",
+ "같은 study에 trial 추가: 아래 join(param_search). Optuna trial은 DB에 쌓임.",
+ "2차만: --phase1-study (MariaDB payload_json). 파일 scp 불필요 · DB_HOST=141.",
+ "순차 전체 재실행: web_cmd_full (env 포함 bash).",
]
- if is_seq:
- hints.append(
- "순차 웹 bash 를 그대로 돌리면 새 study. 아래 python 만 복사."
- )
join_cmd = ""
join_cmd_ps = ""
if strat and study:
@@ -881,21 +1361,43 @@ def build_optuna_join_payload(meta: Dict[str, Any]) -> Dict[str, Any]:
py_bin=".venv/bin/python",
)
argv_ps = _join_argv_for_study(
- m, strategy=strat, study=study, extra=extra, py_bin="python"
+ m, strategy=strat, study=study, extra=extra, py_bin="python",
)
join_cmd = _quote_cmd(argv_sh)
join_cmd_ps = _ps_join_script(argv_ps)
- elif is_seq:
- hints.append("현재 study 가 없으면 첫 전략 START 후 다시 여세요.")
+ elif is_seq or kind == "mode_refine":
+ hints.append("현재 study 없음 — 첫 스텝 START 후 join 갱신.")
join_all: List[Dict[str, str]] = []
+ refine_state = m.get("refine_state") if isinstance(m.get("refine_state"), dict) else {}
+ for sk, label in (
+ ("phase1_study", "1차"),
+ ("phase2_study", "2차"),
+ ):
+ sy = str(refine_state.get(sk) or "").strip()
+ if not sy or not strat:
+ continue
+ a_sh = _join_argv_for_study(
+ m, strategy=strat, study=sy, extra=extra,
+ py_bin=".venv/bin/python",
+ )
+ a_ps = _join_argv_for_study(
+ m, strategy=strat, study=sy, extra=extra, py_bin="python",
+ )
+ join_all.append({
+ "strategy": strat,
+ "extra": extra or label,
+ "study": sy,
+ "cmd": _quote_cmd(a_sh),
+ "cmd_ps": _ps_join_script(a_ps),
+ })
log_path = str(m.get("log_path") or "")
- if is_seq and log_path:
+ if is_seq and log_path and not join_all:
try:
data = Path(log_path).read_bytes()
if len(data) > 400_000:
data = data[-400_000:]
text = data.decode("utf-8", errors="replace")
- seen = set()
+ seen: set = set()
for hit in _SEQ_START_RE.finditer(text):
st = str(hit.group("strat") or "").strip().lower()
ex = str(hit.group("extra") or "").strip()
@@ -908,7 +1410,7 @@ def build_optuna_join_payload(meta: Dict[str, Any]) -> Dict[str, Any]:
py_bin=".venv/bin/python",
)
a_ps = _join_argv_for_study(
- m, strategy=st, study=sy, extra=ex, py_bin="python"
+ m, strategy=st, study=sy, extra=ex, py_bin="python",
)
join_all.append({
"strategy": st,
@@ -921,11 +1423,13 @@ def build_optuna_join_payload(meta: Dict[str, Any]) -> Dict[str, Any]:
join_all = []
return {
"web_cmd": web_cmd,
+ "web_cmd_full": web_cmd_full,
"join_cmd": join_cmd,
"join_cmd_ps": join_cmd_ps,
"join_hint": "\n".join(hints),
"join_study": study,
"join_cmds_all": join_all,
+ "seq_refine_cmds": seq_refine_cmds,
}
@@ -945,6 +1449,423 @@ def _apply_seq_active(m: Dict[str, Any], info: Dict[str, str]) -> None:
m["active_study_name"] = study
+def _parse_study_name_from_log(log_path: str) -> str:
+ """refine/param 로그 CMD 줄에서 --study-name 추출."""
+ p = Path(str(log_path or ""))
+ if not p.is_file():
+ return ""
+ try:
+ head = p.read_text(encoding="utf-8", errors="replace")[:4000]
+ except Exception:
+ return ""
+ m = re.search(r"--study-name\s+(\S+)", head)
+ return str(m.group(1) or "").strip() if m else ""
+
+
+def _resolve_refine_active_study(st: Dict[str, Any]) -> str:
+ """refine_state → 현재 단계 study (phase2_study 없으면 derive)."""
+ phase = str(st.get("phase") or "").strip().lower()
+ p1 = str(st.get("phase1_study") or "").strip()
+ p2 = str(st.get("phase2_study") or "").strip()
+ if phase == "phase2":
+ if p2:
+ return p2
+ if "refine1" in p1:
+ return p1.replace("refine1", "refine2", 1)
+ p2_log = str(st.get("phase2_log") or "")
+ if p2_log:
+ sy = _parse_study_name_from_log(p2_log)
+ if sy:
+ return sy
+ return p1
+
+
+def _trial_to_result_row(trial: Any) -> Optional[Dict[str, Any]]:
+ """Optuna trial → results 행 (mode pool·밴드 근접용)."""
+ ua = dict(getattr(trial, "user_attrs", None) or {})
+ if ua.get("total_pnl") is None:
+ return None
+ raw = ua.get("merged_json") or ua.get("params_json") or "{}"
+ try:
+ merged = json.loads(str(raw))
+ except Exception:
+ merged = dict(getattr(trial, "params", None) or {})
+ if not isinstance(merged, dict):
+ merged = dict(getattr(trial, "params", None) or {})
+ params = dict(getattr(trial, "params", None) or {})
+ try:
+ from kis_trader.backtest.optuna_common import optuna_score_fields_from_trial
+ score_fields = optuna_score_fields_from_trial(trial)
+ except Exception:
+ score_fields = {}
+ row: Dict[str, Any] = {
+ "params": params,
+ "merged_params": merged,
+ "total_trades": ua.get("total_trades"),
+ "win_rate": ua.get("win_rate"),
+ "total_pnl": ua.get("total_pnl"),
+ "pf": ua.get("pf"),
+ "mdd": ua.get("mdd"),
+ "optuna_trial_number": getattr(trial, "number", None),
+ **score_fields,
+ }
+ try:
+ from kis_trader.backtest.optuna_common import stability_fields_from_trial_attrs
+ row.update(stability_fields_from_trial_attrs(trial))
+ except Exception:
+ pass
+ return row
+
+
+def _live_mode_top3(
+ study_name: str,
+ *,
+ start: str,
+ end: str,
+ grid_keys: Optional[List[str]] = None,
+ n: int = 3,
+) -> Optional[Dict[str, Any]]:
+ """진행 중 study — PnL 양수 pool 밴드 근접 mode Top3 + 일평균."""
+ name = str(study_name or "").strip()
+ if not name:
+ return None
+ try:
+ import optuna
+ from kis_trader.backtest.optuna_common import resolve_optuna_storage_url
+ from kis_trader.backtest.optuna_mode_combo import build_results_mode_consensus_tier
+
+ storage = resolve_optuna_storage_url()
+ study = optuna.load_study(study_name=name, storage=storage)
+ complete = optuna.trial.TrialState.COMPLETE
+ results: List[Dict[str, Any]] = []
+ for tr in list(study.trials or []):
+ if getattr(tr, "state", None) != complete:
+ continue
+ row = _trial_to_result_row(tr)
+ if row is not None:
+ results.append(row)
+ if not results:
+ return None
+ data = {"start": start, "end": end, "results": results, "grid_keys": list(grid_keys or [])}
+ mode_rows, mode_meta = build_results_mode_consensus_tier(
+ results,
+ top_n=max(1, int(n)),
+ grid_keys=grid_keys,
+ data=data,
+ )
+ top3: List[Dict[str, Any]] = []
+ for i, row in enumerate(mode_rows[: max(1, int(n))], start=1):
+ _annotate_row_period_daily(row, start=start, end=end)
+ met = _row_metrics(row, label=f"mode #{i}", source="consensus", data=data)
+ if not met:
+ continue
+ for k in ("consensus_match_pct", "consensus_match_n", "consensus_match_of", "consensus_band_err"):
+ if row.get(k) is not None:
+ met[k] = row.get(k)
+ top3.append(met)
+ if not top3:
+ return None
+ ps = str(start or "").strip()[:10]
+ pe = str(end or "").strip()[:10]
+ if not (ps and pe):
+ ps, pe = _period_from_study(name)
+ return {
+ "mode_top3": top3,
+ "mode_pool_size": mode_meta.get("mode_pool_size"),
+ "mode_band_axes": mode_meta.get("band_axes"),
+ "period_range": _fmt_period_range(ps, pe),
+ }
+ except Exception:
+ return None
+
+
+def _breakout_import_label(study: str, data: Dict[str, Any]) -> str:
+ """CLI import 잡 — refine1/2·sl_mode 구분 라벨."""
+ sy = str(study or "")
+ if "refine2" in sy:
+ return "돌파·2차TPE"
+ if "refine1" in sy:
+ return "돌파·1차TPE"
+ sm = ""
+ if "_atr_" in sy or sy.endswith("_atr"):
+ sm = "atr"
+ elif "_fixed_" in sy:
+ sm = "fixed"
+ else:
+ rows = list(data.get("results_all") or data.get("results") or [])
+ p0 = (rows[0] or {}).get("params") or {} if rows else {}
+ sm = str(p0.get("sl_mode") or "").strip()
+ return f"돌파({sm})" if sm else "돌파"
+
+
+def _study_short_note(study: str) -> str:
+ sy = str(study or "").strip()
+ if not sy:
+ return ""
+ if "refine2" in sy:
+ return "refine2"
+ if "refine1" in sy:
+ return "refine1"
+ if len(sy) <= 36:
+ return sy
+ return "…" + sy[-34:]
+
+
+def _trial_to_learn_row(trial: Any) -> Dict[str, Any]:
+ ua = dict(getattr(trial, "user_attrs", None) or {})
+ params: Dict[str, Any] = {}
+ raw = ua.get("params_json")
+ if raw:
+ try:
+ params = json.loads(str(raw))
+ except Exception:
+ params = {}
+ return {
+ "optuna_trial_number": getattr(trial, "number", None),
+ "total_pnl": ua.get("total_pnl"),
+ "win_rate": ua.get("win_rate"),
+ "pf": ua.get("pf"),
+ "total_trades": ua.get("total_trades"),
+ "score": getattr(trial, "value", None),
+ "params": params,
+ }
+
+
+def _annotate_row_period_daily(row: Dict[str, Any], *, start: str, end: str) -> None:
+ from kis_trader.utils.kr_trading_day import count_kr_trading_days
+
+ try:
+ n_days = count_kr_trading_days(str(start or ""), str(end or ""))
+ except Exception:
+ n_days = 1
+ n_days = max(1, int(n_days or 1))
+ try:
+ pnl = float(row.get("total_pnl") or 0)
+ except (TypeError, ValueError):
+ pnl = 0.0
+ row["n_period_trading_days"] = n_days
+ row["period_daily_avg_pnl"] = round(pnl / float(n_days), 2)
+
+
+def _period_from_study(study_name: str) -> tuple:
+ """MariaDB optuna_study_result / payload_json 에서 study 백테 기간."""
+ name = str(study_name or "").strip()
+ if not name:
+ return "", ""
+ try:
+ from kis_trader.backtest.optuna_study_store import load_payload_dict, load_row
+
+ row = load_row(name)
+ if row:
+ s = str(row.get("start_date") or "").strip()[:10]
+ e = str(row.get("end_date") or "").strip()[:10]
+ if s and e:
+ return s, e
+ data = load_payload_dict(name)
+ if data:
+ s = str(data.get("start") or "").strip()[:10]
+ e = str(data.get("end") or "").strip()[:10]
+ if s and e:
+ return s, e
+ except Exception:
+ pass
+ return "", ""
+
+
+def _fmt_period_range(start: str, end: str) -> str:
+ s = str(start or "").strip()[:10]
+ e = str(end or "").strip()[:10]
+ if s and e:
+ return f"{s}~{e}"
+ if s:
+ return s
+ return "—"
+
+
+def _period_slot(label: str, start: str, end: str, study: str = "") -> Dict[str, str]:
+ s = str(start or "").strip()[:10]
+ e = str(end or "").strip()[:10]
+ return {
+ "label": label,
+ "start": s,
+ "end": e,
+ "range": _fmt_period_range(s, e),
+ "study": str(study or "").strip(),
+ }
+
+
+def _infer_refine_study_pair(study_name: str) -> Tuple[str, str]:
+ """refine1/refine2 study 이름 쌍 추론 (import·완료 잡용)."""
+ sn = str(study_name or "").strip()
+ if not sn:
+ return "", ""
+ if "refine2" in sn:
+ p2 = sn
+ p1 = sn.replace("refine2", "refine1", 1)
+ return p1, p2
+ if "refine1" in sn:
+ p1 = sn
+ p2 = sn.replace("refine1", "refine2", 1)
+ return p1, p2
+ return "", ""
+
+
+def _build_period_info(meta: Dict[str, Any]) -> Dict[str, Any]:
+ """잡(메인) · 1·2차 refine · 활성 study 백테 기간 — UI 강조용."""
+ master_s = str(meta.get("start") or "").strip()[:10]
+ master_e = str(meta.get("end") or "").strip()[:10]
+ kind = str(meta.get("kind") or "")
+ rs = meta.get("refine_state") if isinstance(meta.get("refine_state"), dict) else {}
+ out: Dict[str, Any] = {
+ "master": _period_slot("잡(메인)", master_s, master_e),
+ "phase1": _period_slot("1차 TPE", "", "", ""),
+ "phase2": _period_slot("2차 TPE", "", "", ""),
+ "active": _period_slot("활성 study", "", "", ""),
+ "has_refine": False,
+ "same_all": True,
+ "mismatch": False,
+ "mismatch_notes": [],
+ }
+ if kind == "mode_refine" or rs:
+ out["has_refine"] = True
+ p1_study = str(rs.get("phase1_study") or "").strip()
+ p2_study = str(rs.get("phase2_study") or "").strip()
+ phase = str(rs.get("phase") or "").strip().lower()
+ if phase == "phase2" and not p2_study:
+ cand = _resolve_refine_active_study(rs)
+ if cand and cand != p1_study:
+ p2_study = cand
+ p1_s = str(rs.get("start") or master_s)[:10]
+ p1_e = str(rs.get("end") or master_e)[:10]
+ db_s, db_e = _period_from_study(p1_study)
+ if db_s and db_e:
+ p1_s, p1_e = db_s, db_e
+ out["phase1"] = _period_slot("1차 TPE", p1_s, p1_e, p1_study)
+ p2_s, p2_e = p1_s, p1_e
+ if p2_study:
+ db2_s, db2_e = _period_from_study(p2_study)
+ if db2_s and db2_e:
+ p2_s, p2_e = db2_s, db2_e
+ out["phase2"] = _period_slot("2차 TPE", p2_s, p2_e, p2_study)
+ act_study = str(meta.get("active_study_name") or _resolve_refine_active_study(rs))
+ act_s, act_e = master_s, master_e
+ if act_study:
+ ds, de = _period_from_study(act_study)
+ if ds and de:
+ act_s, act_e = ds, de
+ elif phase == "phase2":
+ act_s, act_e = p2_s, p2_e
+ elif phase == "phase1":
+ act_s, act_e = p1_s, p1_e
+ out["active"] = _period_slot("활성 study", act_s, act_e, act_study)
+ else:
+ act_study = str(meta.get("active_study_name") or meta.get("study_name") or "").strip()
+ p1_study, p2_study = _infer_refine_study_pair(act_study)
+ if not p1_study and meta.get("result_json"):
+ p1_from_json = _phase1_study_from_result_json(str(meta.get("result_json") or ""))
+ if p1_from_json:
+ p1_study = str(p1_from_json).strip()
+ if "refine1" in p1_study:
+ p2_study = p1_study.replace("refine1", "refine2", 1)
+ if p2_study and "refine2" in act_study:
+ out["has_refine"] = True
+ p_s, p_e = master_s, master_e
+ ds, de = _period_from_study(act_study)
+ if ds and de:
+ p_s, p_e = ds, de
+ elif master_s and master_e:
+ p_s, p_e = master_s, master_e
+ out["phase1"] = _period_slot("1차 TPE", p_s, p_e, p1_study)
+ out["phase2"] = _period_slot("2차 TPE", p_s, p_e, p2_study or act_study)
+ out["active"] = _period_slot("2차 study", p_s, p_e, act_study)
+ elif act_study:
+ act_s, act_e = master_s, master_e
+ ds, de = _period_from_study(act_study)
+ if ds and de:
+ act_s, act_e = ds, de
+ out["active"] = _period_slot("study", act_s, act_e, act_study)
+ notes: List[str] = []
+ if out["has_refine"]:
+ p1 = out["phase1"]
+ p2 = out["phase2"]
+ if p1.get("start") and (p1["start"], p1["end"]) != (master_s, master_e):
+ notes.append("1차≠잡")
+ if p2.get("study") and (p2["start"], p2["end"]) != (master_s, master_e):
+ notes.append("2차≠잡")
+ if p2.get("study") and p1.get("start") and (p2["start"], p2["end"]) != (p1["start"], p1["end"]):
+ notes.append("1차≠2차")
+ ranges = {
+ (master_s, master_e),
+ }
+ if out["phase1"].get("start"):
+ ranges.add((out["phase1"]["start"], out["phase1"]["end"]))
+ if out["phase2"].get("study") and out["phase2"].get("start"):
+ ranges.add((out["phase2"]["start"], out["phase2"]["end"]))
+ ranges = {r for r in ranges if r[0] and r[1]}
+ out["same_all"] = len(ranges) <= 1
+ out["mismatch"] = bool(notes) or len(ranges) > 1
+ out["mismatch_notes"] = notes
+ if out["has_refine"] and out["same_all"] and master_s and master_e:
+ out["refine_note"] = (
+ "1·2차 TPE 모두 동일 백테 기간(거래일 합산 아님 · 1차=넓은 탐색 → 2차=밴드 축소 재탐색)"
+ )
+ else:
+ out["refine_note"] = ""
+ return out
+
+
+def _live_study_top3(
+ study_name: str,
+ *,
+ start: str,
+ end: str,
+ n: int = 3,
+ label_prefix: str = "learn",
+) -> Optional[Dict[str, Any]]:
+ """진행 중 study — COMPLETE trial TopN (분포·일평균 미리보기)."""
+ name = str(study_name or "").strip()
+ if not name:
+ return None
+ try:
+ import optuna
+ from kis_trader.backtest.optuna_common import resolve_optuna_storage_url
+ from kis_trader.backtest.optuna_study_store import count_study_states
+
+ storage = resolve_optuna_storage_url()
+ study = optuna.load_study(study_name=name, storage=storage)
+ complete = optuna.trial.TrialState.COMPLETE
+ ok = [
+ t for t in list(study.trials or [])
+ if getattr(t, "state", None) == complete
+ and getattr(t, "value", None) is not None
+ ]
+ ok.sort(key=lambda t: float(t.value), reverse=True)
+ top3: List[Dict[str, Any]] = []
+ for i, tr in enumerate(ok[: max(1, int(n))], start=1):
+ row = _trial_to_learn_row(tr)
+ _annotate_row_period_daily(row, start=start, end=end)
+ met = _row_metrics(row, label=f"{label_prefix} #{i}", source="learn")
+ if met:
+ top3.append(met)
+ n_c, n_r, n_f = count_study_states(study)
+ ps = str(start or "").strip()[:10]
+ pe = str(end or "").strip()[:10]
+ if not (ps and pe):
+ ps, pe = _period_from_study(name)
+ return {
+ "study_name": name,
+ "top3_learn": top3,
+ "n_complete": n_c,
+ "n_finished": n_f,
+ "n_running": n_r,
+ "period_start": ps,
+ "period_end": pe,
+ "period_range": _fmt_period_range(ps, pe),
+ }
+ except Exception:
+ return None
+
+
def _study_progress(study_name: str, trials_total: int) -> Dict[str, Any]:
"""Optuna MariaDB study 기준 진행률 + best trial 실측 지표.
@@ -1157,7 +2078,7 @@ def _row_metrics(
for k in (
"stability_score", "n_losing_days", "n_active_days",
"worst_day_pnl", "best_day_pnl", "daily_pnl_mean", "daily_pnl_std",
- "daily_pnl", "period_daily_avg_pnl", "n_period_trading_days",
+ "daily_pnl", "period_daily_avg_pnl", "period_daily_avg_pct", "n_period_trading_days",
):
if row.get(k) is not None:
out[k] = row.get(k)
@@ -1198,6 +2119,7 @@ def _summarize_result_data(
from kis_trader.backtest.optuna_common import (
annotate_optuna_period_daily_avg,
resolve_results_stable,
+ resolve_results_mode_consensus,
)
from kis_trader.backtest.optuna_postprocess_topn import resolve_post_top_n
@@ -1205,6 +2127,7 @@ def _summarize_result_data(
top_n = resolve_post_top_n(10)
gated = list(data.get("results_gated") or [])
stable, stable_gates_resolved = resolve_results_stable(data, top_n=top_n)
+ mode_consensus, mode_consensus_meta = resolve_results_mode_consensus(data, top_n=top_n)
allr = list(data.get("results") or data.get("results_all") or [])
learn = allr[0] if allr else None
gate0 = gated[0] if gated else None
@@ -1267,6 +2190,26 @@ def _summarize_result_data(
mode_row.update(_ob_whip_ui_from_params(mc.get("params") or {}))
except Exception:
pass
+ try:
+ _annotate_row_period_daily(
+ mode_row,
+ start=str(data.get("start") or ""),
+ end=str(data.get("end") or ""),
+ )
+ try:
+ budget = float(
+ data.get("total_budget_krw") or data.get("total_budget") or 0,
+ )
+ except (TypeError, ValueError):
+ budget = 0.0
+ n_days = int(mode_row.get("n_period_trading_days") or 1)
+ pnl_mc = float(mode_row.get("total_pnl") or 0)
+ if budget > 0 and n_days > 0:
+ mode_row["period_daily_avg_pct"] = round(
+ pnl_mc / budget * 100.0 / float(n_days), 3,
+ )
+ except Exception:
+ pass
try:
from kis_trader.backtest.optuna_common import overfit_risk_pct_for_row
of = overfit_risk_pct_for_row(data, mode_row)
@@ -1299,6 +2242,17 @@ def _summarize_result_data(
m["rank"] = i
top5_stable.append(m)
+ top5_consensus: List[Dict[str, Any]] = []
+ for i, row in enumerate(mode_consensus[:top_n], start=1):
+ m = _row_metrics(row, label=f"mode #{i}", source="consensus", data=data)
+ if m:
+ m["rank"] = i
+ if row.get("consensus_match_pct") is not None:
+ m["consensus_match_pct"] = row.get("consensus_match_pct")
+ m["consensus_match_n"] = row.get("consensus_match_n")
+ m["consensus_match_of"] = row.get("consensus_match_of")
+ top5_consensus.append(m)
+
briefing = None
bp = str(path).replace(".json", ".briefing.md")
if Path(bp).is_file():
@@ -1362,6 +2316,7 @@ def _summarize_result_data(
"min_trades_per_day": data.get("min_trades_per_day"),
"n_gated": len(gated),
"n_stable": len(stable),
+ "n_consensus": len(mode_consensus),
"n_all": len(allr),
"optuna_best_trial_number": data.get("optuna_best_trial_number"),
"stable_gates": stable_gates_resolved or data.get("stable_gates"),
@@ -1375,6 +2330,7 @@ def _summarize_result_data(
"method": mc.get("method"),
"top_n": mc.get("top_n"),
"pool_size": mc.get("pool_size"),
+ "pool_kind": mc.get("pool_kind"),
"vs_best": vs if vs else None,
"has_params": bool(mc.get("params")),
},
@@ -1389,6 +2345,8 @@ def _summarize_result_data(
"top5_gated": top5,
"top5_learn": top5_learn,
"top5_stable": top5_stable,
+ "top5_consensus": top5_consensus,
+ "mode_consensus_meta": mode_consensus_meta or data.get("mode_consensus_meta"),
"top5_mode": top5_mode,
# 본 TPE 호가축 여부 · 사후8방 생략 안내
"tpe_includes_orderbook": (
@@ -1459,6 +2417,11 @@ def _pool_for_optuna_source(data: Dict[str, Any], src: str) -> List[Dict[str, An
return list(pool or [])
if s == "gated":
return list(data.get("results_gated") or [])
+ if s in ("consensus", "mode_consensus", "results_mode", "mode_top"):
+ from kis_trader.backtest.optuna_common import resolve_results_mode_consensus
+ from kis_trader.backtest.optuna_postprocess_topn import resolve_post_top_n
+ pool, _ = resolve_results_mode_consensus(data, top_n=resolve_post_top_n(10))
+ return list(pool or [])
return list(data.get("results") or data.get("results_all") or [])
@@ -1852,16 +2815,7 @@ def register_result_json_as_job(
em = str(p0.get("entry_mode") or "").strip()
label = f"꼬리({em})" if em else "꼬리"
elif strat == "breakout":
- sm = ""
- if "_atr_" in study or study.endswith("_atr"):
- sm = "atr"
- elif "_fixed_" in study:
- sm = "fixed"
- else:
- rows = list(data.get("results_all") or data.get("results") or [])
- p0 = (rows[0] or {}).get("params") or {} if rows else {}
- sm = str(p0.get("sl_mode") or "").strip()
- label = f"돌파({sm})" if sm else "돌파"
+ label = _breakout_import_label(study, data)
_labels_imp = {
"momentum": "모멘텀",
"us_momentum": "해외모멘텀",
@@ -1952,6 +2906,72 @@ def _child_jobs_from_jsons(paths: List[str]) -> List[Dict[str, Any]]:
return out
+def _apply_refine_state_progress(
+ m: Dict[str, Any],
+ prog: Dict[str, Any],
+ state_path: Path,
+ *,
+ alive: bool,
+ seq_step: bool = False,
+) -> Optional[str]:
+ """1·2차 refine_state.json → 진행률·활성 study·로그. seq_step=True 면 잡 status 는 건드리지 않음."""
+ if not state_path.is_file():
+ return None
+ active_log: Optional[str] = None
+ try:
+ st = json.loads(state_path.read_text(encoding="utf-8"))
+ m["refine_state"] = st
+ phase = str(st.get("phase") or "")
+ prog["refine_phase"] = phase
+ strat = str(st.get("strategy") or m.get("current_strategy") or "")
+ if strat:
+ m["current_strategy"] = strat
+ if phase == "phase1":
+ prog["label"] = f"{strat or 'Optuna'} · 1차 TPE(넓은 Grid)"
+ elif phase == "phase2":
+ prog["label"] = f"{strat or 'Optuna'} · 2차 TPE(밴드 축소)"
+ elif phase == "done":
+ prog["label"] = f"{strat or 'Optuna'} · 1·2차 완료"
+ active_study = _resolve_refine_active_study(st) if phase in ("phase1", "phase2") else str(st.get("phase1_study") or "")
+ if phase == "phase2" and not active_study:
+ active_study = str(st.get("phase2_study") or st.get("phase1_study") or "")
+ elif phase == "phase1":
+ active_study = str(st.get("phase1_study") or active_study or "")
+ phase_trials = int(m.get("trials") or 0)
+ if phase == "phase2" and st.get("phase2_trials"):
+ try:
+ phase_trials = int(st.get("phase2_trials") or phase_trials)
+ except (TypeError, ValueError):
+ pass
+ if active_study:
+ phase_prog = _study_progress(active_study, phase_trials)
+ for k in (
+ "trials_done", "trials_total", "pct", "best_value", "best_trial",
+ "best_win_rate", "best_pnl", "best_pf", "best_mdd", "best_trades",
+ "study_ok", "error",
+ ):
+ if k in phase_prog and phase_prog.get(k) is not None:
+ prog[k] = phase_prog[k]
+ m["active_study_name"] = active_study
+ if st.get("result_json") and not seq_step:
+ m["result_json"] = st["result_json"]
+ p2_log = st.get("phase2_log")
+ p1_log = st.get("phase1_log")
+ if phase == "phase2" and p2_log and Path(str(p2_log)).is_file():
+ active_log = str(p2_log)
+ elif p1_log and Path(str(p1_log)).is_file():
+ active_log = str(p1_log)
+ if not alive and not seq_step:
+ if phase == "done":
+ m["status"] = "done"
+ elif phase == "error":
+ m["status"] = "error"
+ m["error"] = st.get("error") or m.get("error")
+ except Exception:
+ return active_log
+ return active_log
+
+
def refresh_job_status(meta: Dict[str, Any]) -> Dict[str, Any]:
"""pid/로그/study 로 status·progress 갱신 후 저장."""
m = dict(meta)
@@ -1986,17 +3006,28 @@ def refresh_job_status(meta: Dict[str, Any]) -> Dict[str, Any]:
_apply_seq_active(m, side_info)
elif log_info.get("study") or log_info.get("strategy"):
_apply_seq_active(m, log_info)
- cs = str(m.get("current_strategy") or "").strip().lower()
- if cs:
- lp = ROOT / "logs" / f"optuna_{cs}_tpe_latest.logpath"
- try:
- if lp.is_file():
- val = lp.read_text(encoding="utf-8").strip()
- if val:
- m["active_log_path"] = val
- except Exception:
- pass
- if m.get("active_study_name"):
+ refine_sp = str(side_info.get("refine_state_path") or "").strip()
+ if refine_sp:
+ m["refine_state_path"] = refine_sp
+ rl = _apply_refine_state_progress(
+ m, prog, Path(refine_sp), alive=alive, seq_step=True,
+ )
+ if rl:
+ active_log = rl
+ m["active_log_path"] = rl
+ if not refine_sp:
+ cs = str(m.get("current_strategy") or "").strip().lower()
+ if cs:
+ lp = ROOT / "logs" / f"optuna_{cs}_tpe_latest.logpath"
+ try:
+ if lp.is_file():
+ val = lp.read_text(encoding="utf-8").strip()
+ if val:
+ m["active_log_path"] = val
+ active_log = val
+ except Exception:
+ pass
+ if not refine_sp and m.get("active_study_name"):
prog = _study_progress(
str(m["active_study_name"]), int(m.get("trials") or 0)
)
@@ -2023,6 +3054,94 @@ def refresh_job_status(meta: Dict[str, Any]) -> Dict[str, Any]:
m["status"] = "done"
alive = False
+ if m.get("kind") == "mode_refine":
+ state_path = Path(str(m.get("refine_state_path") or ""))
+ if not state_path.is_file() and m.get("job_id"):
+ state_path = ROOT / "logs" / f"{m.get('job_id')}_refine_state.json"
+ rl = _apply_refine_state_progress(
+ m, prog, state_path, alive=alive, seq_step=False,
+ )
+ if rl:
+ active_log = rl
+ m["period_info"] = _build_period_info(m)
+ if alive:
+ rs = m.get("refine_state") if isinstance(m.get("refine_state"), dict) else {}
+ phase = str(rs.get("phase") or prog.get("refine_phase") or "")
+ act = str(m.get("active_study_name") or _resolve_refine_active_study(rs))
+ pi = m.get("period_info") if isinstance(m.get("period_info"), dict) else {}
+ p1_slot = pi.get("phase1") if isinstance(pi.get("phase1"), dict) else {}
+ p2_slot = pi.get("phase2") if isinstance(pi.get("phase2"), dict) else {}
+ start = str(m.get("start") or "")
+ end = str(m.get("end") or "")
+ live: Dict[str, Any] = {"refine_phase": phase}
+ if act and phase in ("phase1", "phase2"):
+ cur_start = p2_slot.get("start") if phase == "phase2" else p1_slot.get("start") or start
+ cur_end = p2_slot.get("end") if phase == "phase2" else p1_slot.get("end") or end
+ cur = _live_study_top3(
+ act, start=cur_start, end=cur_end, n=5,
+ label_prefix="2차" if phase == "phase2" else "1차",
+ )
+ if cur:
+ live.update(cur)
+ if phase == "phase2":
+ p1s = str(rs.get("phase1_study") or "").strip()
+ if p1s:
+ p1live = _live_study_top3(
+ p1s,
+ start=p1_slot.get("start") or start,
+ end=p1_slot.get("end") or end,
+ n=5,
+ label_prefix="1차",
+ )
+ if p1live:
+ live["phase1_top3"] = p1live.get("top3_learn") or []
+ live["phase1_period_range"] = p1live.get("period_range") or ""
+ gkeys: List[str] = []
+ try:
+ rj = m.get("result_json")
+ if rj and Path(str(rj)).is_file():
+ jd = json.loads(Path(str(rj)).read_text(encoding="utf-8"))
+ gkeys = list(jd.get("grid_keys") or [])
+ except Exception:
+ gkeys = []
+ if act and phase in ("phase1", "phase2"):
+ cur_start = p2_slot.get("start") if phase == "phase2" else p1_slot.get("start") or start
+ cur_end = p2_slot.get("end") if phase == "phase2" else p1_slot.get("end") or end
+ mode_live = _live_mode_top3(
+ act, start=cur_start, end=cur_end, grid_keys=gkeys or None, n=5,
+ )
+ if mode_live:
+ live["mode_top3"] = mode_live.get("mode_top3") or []
+ live["mode_pool_size"] = mode_live.get("mode_pool_size")
+ live["mode_period_range"] = mode_live.get("period_range") or ""
+ if live.get("top3_learn") or live.get("phase1_top3") or live.get("mode_top3"):
+ m["live_summary"] = live
+ else:
+ m.pop("live_summary", None)
+ elif alive and m.get("active_study_name"):
+ act = str(m.get("active_study_name") or "")
+ start = str(m.get("start") or "")
+ end = str(m.get("end") or "")
+ gkeys = []
+ try:
+ rj = m.get("result_json")
+ if rj and Path(str(rj)).is_file():
+ jd = json.loads(Path(str(rj)).read_text(encoding="utf-8"))
+ gkeys = list(jd.get("grid_keys") or [])
+ except Exception:
+ pass
+ learn = _live_study_top3(act, start=start, end=end, n=5, label_prefix="learn")
+ mode_live = _live_mode_top3(act, start=start, end=end, grid_keys=gkeys or None, n=5)
+ live2: Dict[str, Any] = {}
+ if learn:
+ live2.update(learn)
+ if mode_live:
+ live2["mode_top3"] = mode_live.get("mode_top3") or []
+ live2["mode_pool_size"] = mode_live.get("mode_pool_size")
+ live2["mode_period_range"] = mode_live.get("period_range") or ""
+ if live2.get("top3_learn") or live2.get("mode_top3"):
+ m["live_summary"] = live2
+
if alive:
m["status"] = "running"
m["finished_at"] = None
@@ -2085,6 +3204,10 @@ def refresh_job_status(meta: Dict[str, Any]) -> Dict[str, Any]:
except Exception:
m["finished_ts"] = float(m.get("started_ts") or time.time())
+ if m.get("status") in ("done", "error") and not alive:
+ # 진행 중 learn/mode Top3 스냅샷 — 완료 후에는 gated·mode Top3(결과 JSON)만 표시
+ m.pop("live_summary", None)
+
if m.get("status") == "done" and not alive:
# 순차: 1번 스터디 200/200 이어도 프로세스가 살아 있으면 아직 다음 전략
prog["pct"] = 100.0
@@ -2115,6 +3238,34 @@ def refresh_job_status(meta: Dict[str, Any]) -> Dict[str, Any]:
sm = _summarize_result_json(m.get("result_json"))
m["result_summary"] = sm or prev_sum
_attach_study_result_flags(m)
+ if m.get("kind") == "import" and m.get("result_json"):
+ try:
+ rpath = Path(str(m["result_json"]))
+ if rpath.is_file():
+ idata = json.loads(rpath.read_text(encoding="utf-8"))
+ study = str(idata.get("optuna_study_name") or m.get("study_name") or "")
+ if study:
+ m["study_name"] = study
+ m["study_short"] = _study_short_note(study)
+ strat = str(m.get("strategy") or idata.get("strategy") or "").lower()
+ if strat == "breakout":
+ m["label"] = _breakout_import_label(study, idata)
+ bp = Path(str(rpath).replace(".json", ".briefing.md"))
+ need_brief = not bp.is_file()
+ if not need_brief:
+ try:
+ need_brief = "최종 선택 후보" not in bp.read_text(encoding="utf-8")
+ except Exception:
+ need_brief = True
+ if need_brief:
+ from kis_trader.backtest.optuna_briefing import write_briefing_for_json
+ write_briefing_for_json(str(rpath))
+ m["briefing_md"] = str(bp)
+ except Exception:
+ pass
+ m["study_short"] = m.get("study_short") or _study_short_note(
+ str(m.get("study_name") or m.get("active_study_name") or "")
+ )
if m.get("briefing_md") and Path(str(m["briefing_md"])).is_file():
try:
m["briefing_preview"] = Path(str(m["briefing_md"])).read_text(encoding="utf-8")[:4000]
@@ -2192,7 +3343,24 @@ def refresh_job_status(meta: Dict[str, Any]) -> Dict[str, Any]:
post["hint"] = m.get("leftover_note") or "목표 미달 · 이어 돌리기 또는 확정"
m["postprocess"] = post
+ m["period_info"] = _build_period_info(m)
save_job(m)
+ try:
+ if str(m.get("kind") or "") in ("seq", "seq4", "mode_refine"):
+ m["seq_refine_steps"] = [
+ {
+ "step": c.get("step"),
+ "label": c.get("label"),
+ "job_id": c.get("job_id"),
+ "phase1_study": c.get("phase1_study"),
+ "phase1_db": c.get("phase1_db"),
+ "done": c.get("done"),
+ }
+ for c in _build_seq_refine_cmds(m)
+ ]
+ save_job(m)
+ except Exception:
+ pass
m.update(build_optuna_join_payload(m))
return m
@@ -2211,7 +3379,7 @@ def any_optuna_python_running() -> Optional[Dict[str, Any]]:
"""웹 외 CLI nohup 도 상단바에 힌트용."""
try:
r = subprocess.run(
- ["pgrep", "-af", "param_search_optuna.py|run_optuna_4strat_tpe_seq.sh"],
+ ["pgrep", "-af", "param_search_optuna.py|optuna_mode_refine_runner.py|run_optuna_4strat_tpe_seq.sh"],
capture_output=True,
text=True,
timeout=3,
@@ -2220,7 +3388,7 @@ def any_optuna_python_running() -> Optional[Dict[str, Any]]:
for ln in (r.stdout or "").splitlines():
if "extglob" in ln or "pgrep" in ln:
continue
- if "param_search_optuna.py" in ln or "run_optuna_4strat_tpe_seq.sh" in ln:
+ if "param_search_optuna.py" in ln or "optuna_mode_refine_runner.py" in ln or "run_optuna_4strat_tpe_seq.sh" in ln:
lines.append(ln)
if not lines:
return None
@@ -2319,6 +3487,7 @@ def start_optuna_job(
from kis_trader.backtest.optuna_study_store import parse_study_trials_value
st_goal = parse_study_trials_value(study_trials)
reuse_study = str(study_name_override or "").strip()
+ refine_state_path: Optional[str] = None
start = str(start or "").strip()
end = str(end or "").strip()
if not start or not end:
@@ -2398,6 +3567,9 @@ def start_optuna_job(
env["END"] = end
env["TRIALS"] = str(trials)
env["OPTUNA_SEQ_ACTIVE_FILE"] = str(seq_active)
+ env["OPTUNA_SEQ_JOB_ID"] = job_id
+ if sort_by:
+ env["SORT_BY"] = sort_by
if st_goal > 0:
env["STUDY_TRIALS"] = str(st_goal)
env["KIS_OPTUNA_STUDY_TRIALS"] = str(st_goal)
@@ -2424,11 +3596,10 @@ def start_optuna_job(
if "breakout" in picked and bo_sms:
_bo_bits = [f"{sm}×{om}" for sm in bo_sms for om in bo_oms]
_lab = _lab.replace("돌파", "돌파(" + "+".join(_bo_bits) + ")")
- label = "순차(" + _lab + ")"
+ label = "순차1·2차(" + _lab + ")"
strat_field = ",".join(picked)
else:
strat = picked[0]
- job_id = f"opt_{ts}_{strat[:4]}"
if sym and strat == "us_momentum":
study_name = (
f"usmom_{sym}_{mode}_{start.replace('-', '')}_{end.replace('-', '')}_{ts}"
@@ -2454,44 +3625,92 @@ def start_optuna_job(
study_name = f"{strat}_{mode}_{start.replace('-', '')}_{end.replace('-', '')}_{ts}"
log_path = ROOT / "logs" / f"optuna_web_{strat}_{ts}.log"
label = _labels.get(strat, strat)
- cmd = [
- str(PY if PY.is_file() else "python3"),
- "-u",
- str(ROOT / "kis_trader" / "backtest" / "param_search_optuna.py"),
- "--strategy", strat,
- "--mode", mode,
- "--start", start,
- "--end", end,
- "--trials", str(trials),
- "--min_trades", str(min_trades),
- "--min_win_rate", "0",
- "--min_pf", "0",
- "--orderbook-filter", (bo_oms[0] if strat == "breakout" else "off"),
- "--no-progress",
- "--study-name", study_name,
- "--sort-by", sort_by,
- "--universe-history-source", hist_src,
- ]
- if st_goal > 0:
- cmd.extend(["--study-trials", str(st_goal)])
- env["KIS_OPTUNA_STUDY_TRIALS"] = str(st_goal)
- env["PARAM_SEARCH_OPTUNA_STUDY_TRIALS"] = str(st_goal)
- if strat == "tail":
- cmd.extend(["--entry-mode", tail_ems[0]])
- if strat == "breakout":
- cmd.extend(["--sl-mode", bo_sms[0]])
- if candle_source:
- cmd.extend(["--candle-source", candle_source])
- env["CANDLE_SOURCE"] = candle_source
- if tick_source:
- cmd.extend(["--tick-source", tick_source])
- env["TICK_SOURCE"] = tick_source
- if ob_source:
- cmd.extend(["--ob-source", ob_source])
- env["OB_SOURCE"] = ob_source
- if sym and strat == "us_momentum":
- cmd.extend(["--symbol", sym])
- kind = "single"
+
+ refine_state_path = None
+ if reuse_study:
+ # 이어 돌리기: 단일 study (1·2차 연쇄 아님)
+ job_id = f"opt_{ts}_{strat[:4]}"
+ cmd = [
+ str(PY if PY.is_file() else "python3"),
+ "-u",
+ str(ROOT / "kis_trader" / "backtest" / "param_search_optuna.py"),
+ "--strategy", strat,
+ "--mode", mode,
+ "--start", start,
+ "--end", end,
+ "--trials", str(trials),
+ "--min_trades", str(min_trades),
+ "--min_win_rate", "0",
+ "--min_pf", "0",
+ "--orderbook-filter", (bo_oms[0] if strat == "breakout" else "off"),
+ "--no-progress",
+ "--study-name", study_name,
+ "--sort-by", sort_by,
+ "--universe-history-source", hist_src,
+ ]
+ if st_goal > 0:
+ cmd.extend(["--study-trials", str(st_goal)])
+ env["KIS_OPTUNA_STUDY_TRIALS"] = str(st_goal)
+ env["PARAM_SEARCH_OPTUNA_STUDY_TRIALS"] = str(st_goal)
+ if strat == "tail":
+ cmd.extend(["--entry-mode", tail_ems[0]])
+ if strat == "breakout":
+ cmd.extend(["--sl-mode", bo_sms[0]])
+ if candle_source:
+ cmd.extend(["--candle-source", candle_source])
+ env["CANDLE_SOURCE"] = candle_source
+ if tick_source:
+ cmd.extend(["--tick-source", tick_source])
+ env["TICK_SOURCE"] = tick_source
+ if ob_source:
+ cmd.extend(["--ob-source", ob_source])
+ env["OB_SOURCE"] = ob_source
+ if sym and strat == "us_momentum":
+ cmd.extend(["--symbol", sym])
+ kind = "single"
+ else:
+ # 단일 전략 기본: 1차(넓은 Grid) → 2차(밴드 축소) 자동 연쇄
+ job_id = f"opt_{ts}_refine_{strat[:4]}"
+ log_path = ROOT / "logs" / f"optuna_web_refine_{ts}.log"
+ study_name = (
+ f"{strat}_{mode}_refine2_{start.replace('-', '')}_{end.replace('-', '')}_{ts}"
+ )
+ label = f"1·2차TPE·{label}"
+ refine_state_path = str(ROOT / "logs" / f"{job_id}_refine_state.json")
+ cmd = [
+ str(PY if PY.is_file() else "python3"),
+ "-u",
+ str(ROOT / "kis_trader" / "backtest" / "optuna_mode_refine_runner.py"),
+ "--job-id", job_id,
+ "--strategy", strat,
+ "--mode", mode,
+ "--start", start,
+ "--end", end,
+ "--trials", str(trials),
+ "--sort-by", sort_by,
+ "--min-trades", str(min_trades),
+ "--universe-history-source", hist_src,
+ ]
+ if strat == "tail":
+ cmd.extend(["--entry-mode", tail_ems[0]])
+ if strat == "breakout":
+ cmd.extend(["--sl-mode", bo_sms[0], "--ob-mode", bo_oms[0]])
+ if sym and strat == "us_momentum":
+ cmd.extend(["--symbol", sym])
+ if candle_source:
+ cmd.extend(["--candle-source", candle_source])
+ env["CANDLE_SOURCE"] = candle_source
+ if tick_source:
+ cmd.extend(["--tick-source", tick_source])
+ env["TICK_SOURCE"] = tick_source
+ if ob_source:
+ cmd.extend(["--ob-source", ob_source])
+ env["OB_SOURCE"] = ob_source
+ if st_goal > 0:
+ cmd.extend(["--study-trials", str(st_goal)])
+ env["KIS_OPTUNA_STUDY_TRIALS"] = str(st_goal)
+ env["PARAM_SEARCH_OPTUNA_STUDY_TRIALS"] = str(st_goal)
+ kind = "mode_refine"
strat_field = strat
if reuse_study:
@@ -2539,6 +3758,7 @@ def start_optuna_job(
"tail_entry_modes": tail_ems if "tail" in picked else None,
"breakout_sl_modes": bo_sms if "breakout" in picked else None,
"breakout_ob_modes": bo_oms if "breakout" in picked else None,
+ "refine_state_path": refine_state_path if kind == "mode_refine" else None,
"log_path": str(log_path),
"pid": int(proc.pid),
"status": "running",
@@ -2565,8 +3785,8 @@ def continue_optuna_job(job_id: str) -> Dict[str, Any]:
meta = load_job(job_id)
if not meta:
raise FileNotFoundError(f"job not found: {job_id}")
- if str(meta.get("kind") or "") in ("seq", "seq4"):
- raise RuntimeError("순차 잡은 이어 돌리기 불가 — 전략별 보기 잡에서 하세요")
+ if str(meta.get("kind") or "") in ("seq", "seq4", "mode_refine"):
+ raise RuntimeError("순차·1·2차 잡은 이어 돌리기 불가 — 2차 study 또는 전략별 보기 잡에서 하세요")
if str(meta.get("status") or "") != "done":
raise RuntimeError("끝난 잡만 이어 돌리기 가능")
name = _result_study_name(meta)
@@ -2606,8 +3826,8 @@ def confirm_optuna_study(job_id: str) -> Dict[str, Any]:
meta = load_job(job_id)
if not meta:
raise FileNotFoundError(f"job not found: {job_id}")
- if str(meta.get("kind") or "") in ("seq", "seq4"):
- raise RuntimeError("순차 잡은 확정 불가 — 전략별 보기 잡에서 하세요")
+ if str(meta.get("kind") or "") in ("seq", "seq4", "mode_refine"):
+ raise RuntimeError("순차·1·2차 잡은 확정 불가 — 전략별 보기 잡에서 하세요")
if str(meta.get("status") or "") != "done":
raise RuntimeError("끝난 잡만 확정 가능")
name = _result_study_name(meta)
diff --git a/kis_trader/database/db_manager.py b/kis_trader/database/db_manager.py
index 8179322..0501fd2 100644
--- a/kis_trader/database/db_manager.py
+++ b/kis_trader/database/db_manager.py
@@ -108,6 +108,7 @@ class TradeDBExt:
self._migrate_orders_drop_daily_side_unique()
self._migrate_orders_pk_scope()
self._migrate_orders_is_mock()
+ self._migrate_orders_broker_debug_columns()
logger.info("📊 orders 테이블 확인/생성 완료")
except Exception as e:
logger.warning("orders 테이블 생성 실패(무시·폴백): %s", e)
@@ -228,6 +229,25 @@ class TradeDBExt:
except Exception as e:
logger.warning("orders.is_mock 마이그레이션 실패(무시·폴백): %s", e)
+ def _migrate_orders_broker_debug_columns(self) -> None:
+ """orders — cancelable reconcile 디버그 스냅샷 컬럼."""
+ specs = {
+ "broker_open_qty": "INT NULL",
+ "broker_open_odno": "VARCHAR(30) NULL",
+ "broker_reconcile_at": "VARCHAR(30) NULL",
+ "broker_reconcile_note": "VARCHAR(200) NULL",
+ }
+ try:
+ cols = self.conn.get_columns("orders")
+ for col, ddl in specs.items():
+ if col not in cols:
+ self.conn.execute(
+ f"ALTER TABLE orders ADD COLUMN `{col}` {ddl}"
+ )
+ logger.info("📌 orders.%s 컬럼 추가 (reconcile 디버그)", col)
+ except Exception as e:
+ logger.warning("orders broker debug 마이그레이션 실패(무시·폴백): %s", e)
+
# ------------------------------------------------------------------
# orders CRUD
# ------------------------------------------------------------------
@@ -360,23 +380,121 @@ class TradeDBExt:
code: str,
status: str,
ord_date: Optional[str] = None,
+ broker_reconcile_note: Optional[str] = None,
) -> bool:
"""체결 대기 등 — filled_qty 없이 status 만 갱신 (strategy_id·code·ord_date·is_mock 로 행 한정)."""
od = ord_date or datetime.datetime.now().strftime("%Y-%m-%d")
mock_flag = self._resolve_is_mock(None)
+ note = str(broker_reconcile_note or "").strip() or None
+ try:
+ with self.conn:
+ if note:
+ self.conn.execute(
+ "UPDATE orders SET status=%s, broker_reconcile_note=%s, "
+ "broker_reconcile_at=%s "
+ "WHERE ord_no=%s AND strategy_id=%s AND code=%s AND ord_date=%s "
+ "AND is_mock=%s",
+ (
+ status,
+ note[:200],
+ datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
+ ord_no,
+ strategy_id,
+ code,
+ od,
+ mock_flag,
+ ),
+ )
+ else:
+ self.conn.execute(
+ "UPDATE orders SET status=%s "
+ "WHERE ord_no=%s AND strategy_id=%s AND code=%s AND ord_date=%s "
+ "AND is_mock=%s",
+ (status, ord_no, strategy_id, code, od, mock_flag),
+ )
+ return True
+ except Exception as e:
+ logger.error("update_order_status 실패 (%s): %s", ord_no, e)
+ return False
+
+ def update_order_broker_snapshot(
+ self,
+ *,
+ ord_no: str,
+ strategy_id: str,
+ code: str,
+ open_qty: Optional[int] = None,
+ open_odno: Optional[str] = None,
+ note: Optional[str] = None,
+ ord_date: Optional[str] = None,
+ ) -> bool:
+ """cancelable reconcile — 브로커 미체결 스냅샷 기록."""
+ od = ord_date or datetime.datetime.now().strftime("%Y-%m-%d")
+ mock_flag = self._resolve_is_mock(None)
+ now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
try:
with self.conn:
self.conn.execute(
- "UPDATE orders SET status=%s "
- "WHERE ord_no=%s AND strategy_id=%s AND code=%s AND ord_date=%s "
- "AND is_mock=%s",
- (status, ord_no, strategy_id, code, od, mock_flag),
+ """
+ UPDATE orders
+ SET broker_open_qty=%s,
+ broker_open_odno=%s,
+ broker_reconcile_at=%s,
+ broker_reconcile_note=%s
+ WHERE ord_no=%s AND strategy_id=%s AND code=%s
+ AND ord_date=%s AND is_mock=%s
+ """,
+ (
+ int(open_qty) if open_qty is not None else None,
+ str(open_odno or "").strip() or None,
+ now,
+ str(note or "").strip()[:200] or None,
+ ord_no,
+ strategy_id,
+ code,
+ od,
+ mock_flag,
+ ),
)
return True
except Exception as e:
- logger.error("update_order_status 실패 (%s): %s", ord_no, e)
+ logger.error("update_order_broker_snapshot 실패 (%s): %s", ord_no, e)
return False
+ def get_latest_sell_order_for_code(
+ self,
+ strategy_id: str,
+ code: str,
+ *,
+ statuses: Optional[tuple] = None,
+ ) -> Optional[Dict]:
+ """당일·전략·종목 최신 매도 1건 (reconcile용)."""
+ today = datetime.datetime.now().strftime("%Y-%m-%d")
+ mock_flag = self._resolve_is_mock(None)
+ st = statuses or ("CANCELLED", "PENDING_FILL", "SUBMITTED", "PARTIAL", "FILLED")
+ placeholders = ",".join(["%s"] * len(st))
+ try:
+ row = self.conn.execute(
+ f"""
+ SELECT * FROM orders
+ WHERE ord_date=%s AND is_mock=%s
+ AND strategy_id=%s AND code=%s AND side='SELL'
+ AND status IN ({placeholders})
+ ORDER BY submitted_at DESC
+ LIMIT 1
+ """,
+ (today, mock_flag, strategy_id, code, *st),
+ ).fetchone()
+ return dict(row) if row else None
+ except Exception as e:
+ logger.error(
+ "get_latest_sell_order_for_code 실패 (%s/%s): %s",
+ strategy_id,
+ code,
+ e,
+ )
+ return None
+
def get_pending_fill_orders(self) -> List[Dict]:
"""
당일 미확인·부분체결 주문 (체결 qty < 주문 qty).
diff --git a/kis_trader/execution/kis_client.py b/kis_trader/execution/kis_client.py
index fd377a3..c99ec79 100644
--- a/kis_trader/execution/kis_client.py
+++ b/kis_trader/execution/kis_client.py
@@ -37,6 +37,42 @@ from ..utils.request_handler import SafeRequest
logger = get_logger("kis_trader.kis_client")
+
+def log_kis_api_response(
+ tag: str,
+ j: Optional[dict],
+ *,
+ http_status: Optional[int] = None,
+ tr_id: Optional[str] = None,
+ extra: Optional[str] = None,
+ level: str = "warning",
+) -> None:
+ """한투 REST rt_cd/msg_cd/msg1 — 디버그·reconcile용 통일 로그."""
+ if not isinstance(j, dict):
+ j = {}
+ rt_cd = str(j.get("rt_cd") or "")
+ msg_cd = str(j.get("msg_cd") or "")
+ msg1 = str(j.get("msg1") or "")
+ parts = [
+ f"[{tag}]",
+ f"rt_cd={rt_cd or '-'}",
+ f"msg_cd={msg_cd or '-'}",
+ f"msg1={msg1 or '-'}",
+ ]
+ if http_status is not None:
+ parts.append(f"http={http_status}")
+ if tr_id:
+ parts.append(f"tr_id={tr_id}")
+ if extra:
+ parts.append(str(extra))
+ line = " ".join(parts)
+ if level == "info":
+ logger.info(line)
+ elif level == "error":
+ logger.error(line)
+ else:
+ logger.warning(line)
+
# 토큰 캐시 경로 (프로젝트 루트와 동일 위치 공유)
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
@@ -197,6 +233,9 @@ class KISClient(SafeRequest):
self._last_order_msg1: Optional[str] = None
self._last_sell_msg_cd: Optional[str] = None
self._last_sell_msg1: Optional[str] = None
+ self._last_cancel_rt_cd: Optional[str] = None
+ self._last_cancel_msg_cd: Optional[str] = None
+ self._last_cancel_msg1: Optional[str] = None
self._inquire_price_cache: dict = {}
# 같은 요청에서 holdings_map + 계좌요약이 inquire-balance 를 두 번 치지 않게
self._last_inquire_balance: Optional[dict] = None
@@ -971,8 +1010,13 @@ class KISClient(SafeRequest):
return result
def _open_sell_map_from_daily_ccld(self) -> Optional[Dict[str, Dict]]:
- """모의·정정취소가능 미지원 시: 당일 주문체결에서 미체결 매도 잔량."""
- j = self.get_order_history_today(odno="")
+ """모의·정정취소가능 미지원 시: 당일 주문체결에서 미체결 매도 잔량.
+
+ cancelable 전용 페이지 상한: CANCELABLE_CCLD_MAX_PAGES (기본 3).
+ pending fill 일괄 조회는 get_order_history_today() 기본 DAILY_CCLD_MAX_PAGES 유지.
+ """
+ _ccld_max = max(1, int(get_env_int("CANCELABLE_CCLD_MAX_PAGES", 3)))
+ j = self.get_order_history_today(odno="", max_pages=_ccld_max)
if j is None:
return None
out1 = j.get("output1") or []
@@ -1164,18 +1208,25 @@ class KISClient(SafeRequest):
code=code, qty=qty, price=int(price), order_type="00", side="BUY"
)
- def cancel_order(
+ def cancel_order_detail(
self,
org_odno: str,
*,
org_branch: str = "",
qty: int = 0,
order_dvsn: str = "00",
- ) -> bool:
- """미체결 주문 전량 취소 (RVSE_CNCL_DVSN_CD=02)."""
+ ) -> Dict[str, object]:
+ """미체결 주문 전량 취소 (RVSE_CNCL_DVSN_CD=02). rt_cd/msg_cd/msg1 포함."""
odno = str(org_odno or "").strip()
+ empty = {
+ "ok": False,
+ "rt_cd": "",
+ "msg_cd": "",
+ "msg1": "",
+ "http_status": 0,
+ }
if not odno:
- return False
+ return empty
tr_id = "VTTC0803U" if self.mock else "TTTC0803U"
path = "/uapi/domestic-stock/v1/trading/order-rvsecncl"
body = {
@@ -1191,20 +1242,87 @@ class KISClient(SafeRequest):
}
try:
r = self._post(path, tr_id, body)
+ http_st = int(getattr(r, "status_code", 0) or 0)
if r.status_code != 200:
- logger.error("주문취소 HTTP 에러 odno=%s status=%s", odno, r.status_code)
- return False
+ log_kis_api_response(
+ "cancel_order",
+ None,
+ http_status=http_st,
+ tr_id=tr_id,
+ extra=f"odno={odno}",
+ )
+ self._last_cancel_rt_cd = f"HTTP_{http_st}"
+ self._last_cancel_msg_cd = ""
+ self._last_cancel_msg1 = (r.text or "")[:200]
+ return {
+ "ok": False,
+ "rt_cd": self._last_cancel_rt_cd,
+ "msg_cd": "",
+ "msg1": self._last_cancel_msg1,
+ "http_status": http_st,
+ }
j = r.json()
- if j.get("rt_cd") == "0":
- return True
- logger.warning(
- "주문취소 실패 odno=%s rt_cd=%s msg=%s",
- odno, j.get("rt_cd"), j.get("msg1"),
- )
- return False
+ rt_cd = str(j.get("rt_cd") or "")
+ msg_cd = str(j.get("msg_cd") or "")
+ msg1 = str(j.get("msg1") or "")
+ self._last_cancel_rt_cd = rt_cd
+ self._last_cancel_msg_cd = msg_cd
+ self._last_cancel_msg1 = msg1
+ ok = rt_cd == "0"
+ if ok:
+ log_kis_api_response(
+ "cancel_order",
+ j,
+ http_status=http_st,
+ tr_id=tr_id,
+ extra=f"odno={odno} ok",
+ level="info",
+ )
+ else:
+ log_kis_api_response(
+ "cancel_order",
+ j,
+ http_status=http_st,
+ tr_id=tr_id,
+ extra=f"odno={odno}",
+ )
+ return {
+ "ok": ok,
+ "rt_cd": rt_cd,
+ "msg_cd": msg_cd,
+ "msg1": msg1,
+ "http_status": http_st,
+ }
except Exception as e:
logger.error("주문취소 예외 odno=%s: %s", odno, e)
- return False
+ self._last_cancel_rt_cd = "EXC"
+ self._last_cancel_msg_cd = "EXC"
+ self._last_cancel_msg1 = str(e)[:200]
+ return {
+ "ok": False,
+ "rt_cd": "EXC",
+ "msg_cd": "EXC",
+ "msg1": self._last_cancel_msg1,
+ "http_status": 0,
+ }
+
+ def cancel_order(
+ self,
+ org_odno: str,
+ *,
+ org_branch: str = "",
+ qty: int = 0,
+ order_dvsn: str = "00",
+ ) -> bool:
+ """미체결 주문 전량 취소 (RVSE_CNCL_DVSN_CD=02)."""
+ return bool(
+ self.cancel_order_detail(
+ org_odno,
+ org_branch=org_branch,
+ qty=qty,
+ order_dvsn=order_dvsn,
+ ).get("ok")
+ )
def sell_limit_order(self, code: str, qty: int, price: int) -> Optional[str]:
"""지정가 매도 (ORD_DVSN=00). 익절 시 매수 1호가 등."""
@@ -1615,7 +1733,9 @@ class KISClient(SafeRequest):
logger.debug("조건검색 결과 조회 실패 (seq=%s): %s", seq, e)
return []
- def get_order_history_today(self, odno: str = "") -> Optional[dict]:
+ def get_order_history_today(
+ self, odno: str = "", max_pages: Optional[int] = None
+ ) -> Optional[dict]:
"""당일 주문 내역 조회 [국내주식-005 inquire-daily-ccld].
TR_ID: 공식 샘플 기준 3개월이내 = 실전 ``TTTC0081R`` / 모의 ``VTTC0081R``.
@@ -1623,6 +1743,7 @@ class KISClient(SafeRequest):
``odno`` 지정 시 해당 주문번호만 조회. 미지정 시 연속조회(모의 15건/페이지)로
output1 을 병합해 반환한다.
+ ``max_pages`` 미지정 시 DAILY_CCLD_MAX_PAGES(기본 20).
"""
# 공식 OpenAPI 샘플(inquire_daily_ccld) — env 로만 레거시 TR 오버라이드
default_tr = "VTTC0081R" if self.mock else "TTTC0081R"
@@ -1631,14 +1752,15 @@ class KISClient(SafeRequest):
).strip() or default_tr
today = dt.now().strftime("%Y%m%d")
# 모의 1페이지≈15건 — 장중 체결 누락 방지. 과도 연속조회 방지 상한.
- max_pages = max(1, int(get_env_int("DAILY_CCLD_MAX_PAGES", 20)))
+ _default_max = max(1, int(get_env_int("DAILY_CCLD_MAX_PAGES", 20)))
+ page_limit = max(1, int(max_pages)) if max_pages is not None else _default_max
try:
merged_out1: list = []
last_j: Optional[dict] = None
fk100 = ""
nk100 = ""
tr_cont = ""
- for _page in range(max_pages):
+ for _page in range(page_limit):
r = self._get(
"/uapi/domestic-stock/v1/trading/inquire-daily-ccld",
tr_id,
@@ -1666,11 +1788,25 @@ class KISClient(SafeRequest):
if r is None or r.status_code != 200:
if merged_out1 and last_j is not None:
break
+ log_kis_api_response(
+ "daily_ccld",
+ None,
+ http_status=getattr(r, "status_code", None),
+ tr_id=tr_id,
+ extra=f"odno={odno or '-'} page={_page + 1}",
+ )
return None
j = r.json()
if j.get("rt_cd") != "0":
if merged_out1 and last_j is not None:
break
+ log_kis_api_response(
+ "daily_ccld",
+ j,
+ http_status=r.status_code,
+ tr_id=tr_id,
+ extra=f"odno={odno or '-'} page={_page + 1}",
+ )
return None
last_j = j
chunk = j.get("output1") or []
diff --git a/kis_trader/execution/order_manager.py b/kis_trader/execution/order_manager.py
index 285a3c7..6f91528 100644
--- a/kis_trader/execution/order_manager.py
+++ b/kis_trader/execution/order_manager.py
@@ -17,11 +17,13 @@ kis_trader/execution/order_manager.py — Master Executor
* ``REAL_BALANCE_VERIFY_BEFORE_SELL`` (기본 True): 매도 전 실잔고 조회.
보유(`hldg_qty`)와 매도가능(`ord_psbl_qty`)을 분리. 매도가능 0 ≠ 유령.
``orders`` 미체결(pending_sell) 가드와 **역할이 다름** — 중복주문 vs 증권사 진실.
- * ``BROKER_HOLDINGS_CACHE_TTL_SEC`` (기본 5): 잔고 캐시 TTL — 매도 **루프** 내 N종목 공유.
- 틱매도는 ``prefetch_broker_holdings(force=True)`` 라 TTL 을 안 탐.
+ * ``BROKER_HOLDINGS_CACHE_TTL_SEC`` (기본 5): 잔고 캐시 TTL — AccountOrderWorker·place 공유.
* ``INQUIRE_PSBL_RVSECNCL_BEFORE_GHOST`` (기본 True): 유령정리 전 정정취소가능주문조회.
시장에 남은 매도가 있으면 ghost_purge 금지.
- * ``PSBL_RVSECNCL_CACHE_TTL_SEC`` (기본 5): 정정취소가능 조회 캐시 (유량 보호).
+ * ``PSBL_RVSECNCL_CACHE_TTL_SEC`` (기본 30): 정정취소가능 조회 캐시 (유량 보호).
+ * ``CANCELABLE_CCLD_MAX_PAGES`` (기본 3): 모의 cancelable daily-ccld 페이지 상한.
+ * ``SELL_LOCKED_ENQUEUE_COOLDOWN_SEC`` (기본 20): sell_locked 시 전략 enqueue 쿨다운.
+ * ``CANCELABLE_RECONCILE_ENABLED`` / ``CANCELABLE_RECONCILE_CANCEL_RETRY``: DB↔브로커 정합.
* ``GHOST_POSITION_COOLDOWN_SEC`` (기본 300): 유령잔고 정리 후 동일 (전략,종목) 재시도 쿨다운.
* ``REAL_BALANCE_VERIFY_BEFORE_BUY`` (기본 False): 매수 전 검증 (기본 OFF — 전략별 복합키가 주 관리).
* ``REAL_BALANCE_VERIFY_BEFORE_BUY_MODE`` — ``strategy``(기본): 동일 전략 DB 보유 시만 차단 /
@@ -264,12 +266,12 @@ class OrderManager:
self._holdings_cache_ts = now
return dict(m)
- def prefetch_broker_holdings(self) -> bool:
+ def prefetch_broker_holdings(self, *, force: bool = True) -> bool:
"""
- 전략 매도 루프 시작 전 1회 호출 (force=True → TTL 무시).
- 틱매도 콜백에서도 동일 함수를 써서, 틱마다 잔고 REST 가 날아갈 수 있다.
+ 매도 place 전 잔고 warm-up (force=True → TTL 무시).
+ AccountOrderWorker 직렬 환경에서는 force=False 로 연속 SELL TTL 캐시 활용.
"""
- self.get_broker_holdings(force=True)
+ self.get_broker_holdings(force=force)
return self._holdings_last_fetch_ok
def invalidate_holdings_cache(self) -> None:
@@ -287,7 +289,7 @@ class OrderManager:
if not get_env_bool("INQUIRE_PSBL_RVSECNCL_BEFORE_GHOST", True):
return {}
now = time.time()
- ttl = float(get_env_int("PSBL_RVSECNCL_CACHE_TTL_SEC", 5))
+ ttl = float(get_env_int("PSBL_RVSECNCL_CACHE_TTL_SEC", 30))
with self._holdings_lock:
if (
not force
@@ -336,6 +338,154 @@ class OrderManager:
return "cancelable_open"
return ""
+ def _reconcile_cancelable_open(
+ self,
+ req: OrderRequest,
+ cmap: Optional[Dict[str, Dict]],
+ ) -> str:
+ """
+ DB CANCELLED vs 브로커 cancelable open 불일치 reconcile.
+ 반환 action: disabled, noop, snapshot_only, cancel_ok, reverted_pending, filled.
+ """
+ if not get_env_bool("CANCELABLE_RECONCILE_ENABLED", True):
+ return "disabled"
+ code = str(req.code or "").strip()
+ if not code:
+ return "noop"
+ open_qty = cancelable_remainder_qty(cmap, code)
+ if open_qty <= 0:
+ return "noop"
+ row = (cmap or {}).get(code) or {}
+ open_odno = str(row.get("odno") or "").strip()
+ db_row = self.db.get_latest_sell_order_for_code(req.strategy_id, code)
+ db_status = str((db_row or {}).get("status") or "")
+ db_ord_no = str((db_row or {}).get("ord_no") or "").strip()
+ snap_odno = open_odno or db_ord_no
+ if db_row and snap_odno:
+ try:
+ self.db.update_order_broker_snapshot(
+ ord_no=snap_odno,
+ strategy_id=req.strategy_id,
+ code=code,
+ open_qty=open_qty,
+ open_odno=open_odno or None,
+ note="cancelable_open",
+ )
+ except Exception as ex:
+ logger.debug("broker snapshot 저장 실패 %s: %s", code, ex)
+
+ if open_odno:
+ try:
+ fill = self.client.get_execution_by_odno(
+ open_odno, code=code, wait_sec=0.0,
+ )
+ except Exception:
+ fill = None
+ if fill and int(fill.get("filled_qty") or 0) > 0:
+ if db_row and db_ord_no:
+ try:
+ self.db.update_order_broker_snapshot(
+ ord_no=db_ord_no,
+ strategy_id=req.strategy_id,
+ code=code,
+ open_qty=open_qty,
+ open_odno=open_odno,
+ note="filled_on_reconcile",
+ )
+ except Exception:
+ pass
+ logger.warning(
+ "[cancelable_reconcile] %s action=filled open_qty=%d odno=%s "
+ "db_status=%s db_ord_no=%s",
+ code, open_qty, open_odno, db_status, db_ord_no or "-",
+ )
+ return "filled"
+
+ cancel_detail: Dict[str, object] = {}
+ if (
+ open_odno
+ and open_qty > 0
+ and get_env_bool("CANCELABLE_RECONCILE_CANCEL_RETRY", True)
+ ):
+ try:
+ cancel_detail = self.client.cancel_order_detail(
+ open_odno, qty=int(open_qty),
+ )
+ except Exception as ex:
+ cancel_detail = {
+ "ok": False,
+ "rt_cd": "EXC",
+ "msg_cd": "EXC",
+ "msg1": str(ex)[:200],
+ "http_status": 0,
+ }
+ if cancel_detail.get("ok"):
+ note = "cancel_ok"
+ if db_row and snap_odno:
+ try:
+ self.db.update_order_broker_snapshot(
+ ord_no=snap_odno,
+ strategy_id=req.strategy_id,
+ code=code,
+ open_qty=0,
+ open_odno=open_odno,
+ note=note,
+ )
+ except Exception:
+ pass
+ logger.warning(
+ "[cancelable_reconcile] %s action=%s open_qty=%d odno=%s "
+ "db_status=%s rt_cd=%s msg_cd=%s msg1=%s",
+ code, note, open_qty, open_odno, db_status,
+ cancel_detail.get("rt_cd", "-"),
+ cancel_detail.get("msg_cd", "-"),
+ cancel_detail.get("msg1", "-"),
+ )
+ self.invalidate_holdings_cache()
+ return "cancel_ok"
+ if db_row and db_status == "CANCELLED" and snap_odno:
+ try:
+ self.db.update_order_status(
+ ord_no=snap_odno,
+ strategy_id=req.strategy_id,
+ code=code,
+ status="PENDING_FILL",
+ broker_reconcile_note="reverted_pending",
+ )
+ self.db.update_order_broker_snapshot(
+ ord_no=snap_odno,
+ strategy_id=req.strategy_id,
+ code=code,
+ open_qty=open_qty,
+ open_odno=open_odno or None,
+ note="reverted_pending",
+ )
+ except Exception as ex:
+ logger.warning(
+ "[cancelable_reconcile] %s revert 실패: %s", code, ex,
+ )
+ return "noop"
+ logger.warning(
+ "[cancelable_reconcile] %s action=reverted_pending open_qty=%d "
+ "odno=%s db_status=%s rt_cd=%s msg_cd=%s msg1=%s",
+ code, open_qty, open_odno or db_ord_no, db_status,
+ cancel_detail.get("rt_cd", "-"),
+ cancel_detail.get("msg_cd", "-"),
+ cancel_detail.get("msg1", "-"),
+ )
+ self.invalidate_holdings_cache()
+ return "reverted_pending"
+
+ logger.warning(
+ "[cancelable_reconcile] %s action=snapshot_only open_qty=%d odno=%s "
+ "db_status=%s db_ord_no=%s rt_cd=%s msg_cd=%s msg1=%s",
+ code, open_qty, open_odno or "-", db_status, db_ord_no or "-",
+ cancel_detail.get("rt_cd", "-") if cancel_detail else "-",
+ cancel_detail.get("msg_cd", "-") if cancel_detail else "-",
+ cancel_detail.get("msg1", "-") if cancel_detail else "-",
+ )
+ return "snapshot_only"
+
def _resolve_order_display_name(self, req: OrderRequest) -> str:
"""MM·DB·로그용 종목명 — code=이름이면 DB/잔고에서 보완."""
fb = str(req.name or req.code or "").strip()
@@ -1202,6 +1352,7 @@ class OrderManager:
# 만료 — 미체결 또는 부분체결 잔량 정리
remain = max(0, order_qty - prev_filled)
+ cancel_ok = True
if remain > 0:
# 매도 IOC면 잔량 주문은 이미 브로커 취소 — cancel REST 생략
skip_cancel = (
@@ -1210,10 +1361,42 @@ class OrderManager:
)
if not skip_cancel:
try:
- self.client.cancel_order(ord_no, qty=remain)
+ cancel_detail = self.client.cancel_order_detail(
+ ord_no, qty=remain,
+ )
+ cancel_ok = bool(cancel_detail.get("ok"))
+ if not cancel_ok:
+ logger.warning(
+ "%s⏱ [만료취소실패] %s %s ODNO=%s remain=%d "
+ "rt_cd=%s msg_cd=%s msg1=%s — CANCELLED 금지%s",
+ LOG_YELLOW, req.name, req.code, ord_no, remain,
+ cancel_detail.get("rt_cd", "-"),
+ cancel_detail.get("msg_cd", "-"),
+ cancel_detail.get("msg1", "-"),
+ LOG_RESET,
+ )
+ try:
+ self.db.update_order_broker_snapshot(
+ ord_no=ord_no,
+ strategy_id=req.strategy_id,
+ code=req.code,
+ open_qty=remain,
+ open_odno=ord_no,
+ note="cancel_failed_at_poll",
+ )
+ except Exception:
+ pass
except Exception as e:
- logger.debug("만료 주문 취소 실패 ord_no=%s: %s", ord_no, e)
+ cancel_ok = False
+ logger.warning(
+ "%s⏱ [만료취소예외] %s %s ODNO=%s remain=%d: %s "
+ "— CANCELLED 금지%s",
+ LOG_YELLOW, req.name, req.code, ord_no, remain,
+ e, LOG_RESET,
+ )
if prev_filled <= 0:
+ if not cancel_ok and remain > 0:
+ continue
self.db.update_order_status(
ord_no=ord_no, strategy_id=req.strategy_id, code=req.code,
status="CANCELLED",
@@ -2394,12 +2577,67 @@ class OrderManager:
)
if not broker_row_still_held(real_row):
if block:
- logger.warning(
- "%s⏸ [매도보류] [%s] %s %s — 잔고맵 0이지만 %s "
- "(시장 미체결/API실패) → 유령정리 안 함%s",
- LOG_YELLOW, req.strategy_id, req.name, req.code,
- block, LOG_RESET,
- )
+ cmap = None
+ open_qty = 0
+ open_odno = ""
+ db_status = ""
+ db_ord_no = ""
+ rt_cd = msg_cd = msg1 = "-"
+ if block == "cancelable_open":
+ cmap = self.get_cancelable_sells(force=False)
+ open_qty = cancelable_remainder_qty(cmap, req.code)
+ row_c = (cmap or {}).get(req.code) or {}
+ open_odno = str(row_c.get("odno") or "")
+ db_row = self.db.get_latest_sell_order_for_code(
+ req.strategy_id, req.code,
+ )
+ db_status = str((db_row or {}).get("status") or "")
+ db_ord_no = str((db_row or {}).get("ord_no") or "")
+ reconcile_action = self._reconcile_cancelable_open(
+ req, cmap,
+ )
+ rt_cd = str(
+ getattr(self.client, "_last_cancel_rt_cd", None) or "-"
+ )
+ msg_cd = str(
+ getattr(self.client, "_last_cancel_msg_cd", None) or "-"
+ )
+ msg1 = str(
+ getattr(self.client, "_last_cancel_msg1", None) or "-"
+ )
+ if reconcile_action == "reverted_pending":
+ pend_no = db_ord_no or open_odno
+ logger.warning(
+ "%s⏸ [매도대기중] [%s] %s %s — reconcile "
+ "PENDING 복구 ODNO=%s%s",
+ LOG_YELLOW, req.strategy_id, req.name,
+ req.code, pend_no, LOG_RESET,
+ )
+ return OrderResult(
+ False,
+ ord_no=pend_no or None,
+ reason="sell_order_pending",
+ request=req,
+ )
+ if reconcile_action == "cancel_ok":
+ block = self._ghost_purge_block_reason(
+ req.code, req.strategy_id, real_row,
+ )
+ if not block:
+ return self._purge_ghost_position(
+ req, "broker_zero",
+ )
+ if block:
+ logger.warning(
+ "%s⏸ [매도보류] [%s] %s %s — 잔고맵 0이지만 %s "
+ "(시장 미체결/API실패) → 유령정리 안 함 "
+ "broker_open_qty=%d odno=%s db_last_status=%s db_ord_no=%s "
+ "rt_cd=%s msg_cd=%s msg1=%s%s",
+ LOG_YELLOW, req.strategy_id, req.name, req.code,
+ block, open_qty, open_odno or "-",
+ db_status or "-", db_ord_no or "-",
+ rt_cd, msg_cd, msg1, LOG_RESET,
+ )
return OrderResult(
False,
reason="sell_locked:%s" % block,
diff --git a/kis_trader/execution/order_worker.py b/kis_trader/execution/order_worker.py
index 03a575c..27f7a34 100644
--- a/kis_trader/execution/order_worker.py
+++ b/kis_trader/execution/order_worker.py
@@ -144,7 +144,7 @@ class AccountOrderWorker:
return
if side == "SELL" and get_env_bool("REAL_BALANCE_VERIFY_BEFORE_SELL", True):
try:
- self.order_mgr.prefetch_broker_holdings()
+ self.order_mgr.prefetch_broker_holdings(force=False)
except Exception:
pass
if side == "BUY":
diff --git a/kis_trader/strategies/base.py b/kis_trader/strategies/base.py
index 034bc9e..798a4c7 100644
--- a/kis_trader/strategies/base.py
+++ b/kis_trader/strategies/base.py
@@ -207,6 +207,8 @@ class BaseStrategy(ABC, threading.Thread):
# 종목당 매도/매수 intent 1장 — enqueue 중복 방지 (Worker 완료 시 해제)
self._sell_inflight: set = set()
self._buy_inflight: set = set()
+ # sell_locked/sellable_zero/pending 시 tick enqueue 쿨다운 (SELL_LOCKED_ENQUEUE_COOLDOWN_SEC)
+ self._sell_locked_until: Dict[str, float] = {}
self._order_enqueue_skip = 0
# 루프 숙제별 ms 계측 (LOOP_PROFILE_ENABLED)
self._loop_prof_i = 0
@@ -282,6 +284,18 @@ class BaseStrategy(ABC, threading.Thread):
code = str((sig or {}).get("code") or "").strip()
if not code:
return False
+ cooldown_sec = max(
+ 0, int(get_env_int("SELL_LOCKED_ENQUEUE_COOLDOWN_SEC", 20) or 0),
+ )
+ if cooldown_sec > 0:
+ until = self._sell_locked_until.get(code, 0.0)
+ if time.time() < until:
+ remain = int(until - time.time())
+ self.logger.debug(
+ "⏸ [매도enqueue쿨다운] %s — %d초 남음 (source=%s)",
+ code, remain, source,
+ )
+ return False
if not self._mark_sell_inflight(code):
return False
ow = getattr(self, "order_worker", None)
@@ -1428,6 +1442,15 @@ class BaseStrategy(ABC, threading.Thread):
profit_pct=float(signal.get("profit_pct", 0)),
)
result = self.order_mgr.place(req)
+ reason = str(result.reason or "")
+ cooldown_sec = max(
+ 0, int(get_env_int("SELL_LOCKED_ENQUEUE_COOLDOWN_SEC", 20) or 0),
+ )
+ if cooldown_sec > 0 and (
+ reason.startswith("sell_locked:")
+ or reason in ("sellable_zero", "sell_order_pending")
+ ):
+ self._sell_locked_until[req.code] = time.time() + float(cooldown_sec)
if result.success:
self.recently_sold[req.code] = time.time()
self._drop_local_position(req.code)
diff --git a/kis_trader/web/live_config_schema.py b/kis_trader/web/live_config_schema.py
index cf8eebb..30bb3fc 100644
--- a/kis_trader/web/live_config_schema.py
+++ b/kis_trader/web/live_config_schema.py
@@ -1069,8 +1069,39 @@ def build_live_config_groups() -> List[GroupDef]:
"PSBL_RVSECNCL_CACHE_TTL_SEC",
"📋 정정취소가능 조회 캐시(초)",
"int",
- default=5,
- hint="같은 초 안에 종목마다 REST 연타 방지. 잔고 캐시 TTL 과 비슷하게.",
+ default=30,
+ hint="같은 초 안에 종목마다 REST 연타 방지. sell_locked retry 시 캐시 hit.",
+ ),
+ _f(
+ "CANCELABLE_CCLD_MAX_PAGES",
+ "📋 모의 cancelable daily-ccld 최대 페이지",
+ "int",
+ default=3,
+ hint="모의투자 cancelable 조회 시 daily-ccld 페이지 상한. 기본 3 (유량·지연 보호).",
+ ),
+ _f(
+ "SELL_LOCKED_ENQUEUE_COOLDOWN_SEC",
+ "⏸ sell_locked enqueue 쿨다운(초)",
+ "int",
+ default=20,
+ hint=(
+ "sell_locked/sellable_zero/pending 시 tick·scan 재 enqueue 대기. "
+ "REST 폭주 방지 (SELL_FAILURE_BACKOFF 와 별개)."
+ ),
+ ),
+ _f(
+ "CANCELABLE_RECONCILE_ENABLED",
+ "🔄 cancelable_open DB↔브로커 reconcile",
+ "bool",
+ default=True,
+ hint="ON = DB CANCELLED vs 브로커 미체결 open 불일치 시 자동 조사·복구 시도.",
+ ),
+ _f(
+ "CANCELABLE_RECONCILE_CANCEL_RETRY",
+ "🔄 reconcile 시 cancel_order 재시도",
+ "bool",
+ default=True,
+ hint="reconcile 중 브로커 미체결 ODNO 에 cancel_order 1회 재시도.",
),
_f(
"PSBL_RVSECNCL_MAX_PAGES",
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/scripts/run_optuna_4strat_tpe_seq.sh b/scripts/run_optuna_4strat_tpe_seq.sh
index 63e8bb1..7b76b46 100755
--- a/scripts/run_optuna_4strat_tpe_seq.sh
+++ b/scripts/run_optuna_4strat_tpe_seq.sh
@@ -38,6 +38,9 @@ BREAKOUT_OPTUNA_SL_MODES="${BREAKOUT_OPTUNA_SL_MODES:-fixed}"
BREAKOUT_OPTUNA_OB_MODES="${BREAKOUT_OPTUNA_OB_MODES:-off}"
# kiwoom|ls — 웹 Optuna 이력소스 / CLI UNIVERSE_HISTORY_SOURCE
UNIVERSE_HISTORY_SOURCE="${UNIVERSE_HISTORY_SOURCE:-${BACKTEST_UNIVERSE_HISTORY_SOURCE:-kiwoom}}"
+# 웹 순차 잡 ID (전략/조합별 1·2차 state 파일 prefix)
+OPTUNA_SEQ_JOB_ID="${OPTUNA_SEQ_JOB_ID:-seq}"
+SORT_BY="${SORT_BY:-score}"
PY="${PY:-.venv/bin/python}"
TS0="$(date +%Y%m%d_%H%M%S)"
MASTER="logs/optuna_4strat_tpe_${START}_${END}_${TS0}_master.log"
@@ -51,7 +54,7 @@ MASTER="logs/optuna_4strat_tpe_${START}_${END}_${TS0}_master.log"
echo "BREAKOUT_OPTUNA_OB_MODES=$BREAKOUT_OPTUNA_OB_MODES"
echo "UNIVERSE_HISTORY_SOURCE=$UNIVERSE_HISTORY_SOURCE"
echo "min_wr=$MIN_WIN_RATE min_pf=$MIN_PF min_trades=$MIN_TRADES"
- echo "apply-best=OFF breakout-orderbook=스위치(스터디별) n_jobs=1 (사후 results_gated + briefing.md)"
+ echo "apply-best=OFF · 전략/조합마다 1·2차 TPE 연쇄 · n_jobs=1 (사후 results_gated + briefing.md)"
echo "master_log=$MASTER"
free -h | sed -n '1,2p'
df -h / | tail -1
@@ -63,97 +66,106 @@ run_one() {
local entry_mode="${2:-}"
local sl_mode="${3:-}"
local ob_mode="${4:-off}"
- local ts study log sort_by bo_extra
+ local ts step_job_id refine_log state_file sort_by bo_extra extra min_trades_arg
ts="$(date +%Y%m%d_%H%M%S)"
- study="${strat}_tpe_${START//-/}_${END//-/}_${ts}"
- log="logs/optuna_${strat}_tpe_${ts}.log"
- if [[ "$strat" == "tail" && -n "$entry_mode" ]]; then
- study="${strat}_${entry_mode}_tpe_${START//-/}_${END//-/}_${ts}"
- log="logs/optuna_${strat}_${entry_mode}_tpe_${ts}.log"
- fi
- if [[ "$strat" == "breakout" && -n "$sl_mode" ]]; then
- bo_extra="${sl_mode}_ob_${ob_mode}"
- study="${strat}_${bo_extra}_tpe_${START//-/}_${END//-/}_${ts}"
- log="logs/optuna_${strat}_${bo_extra}_tpe_${ts}.log"
- fi
sort_by="${SORT_BY:-score}"
case "$sort_by" in
score|score_legacy|pnl|daily_avg|win_rate) ;;
*) sort_by="score" ;;
esac
- local min_trades_arg
+ if [[ "$strat" == "breakout" && -n "$sl_mode" ]]; then
+ bo_extra="${sl_mode}_ob_${ob_mode}"
+ extra="$bo_extra"
+ elif [[ "$strat" == "tail" && -n "$entry_mode" ]]; then
+ extra="$entry_mode"
+ else
+ extra=""
+ fi
+
+ step_job_id="${OPTUNA_SEQ_JOB_ID}_${strat}${extra:+_${extra}}_${ts}"
+ refine_log="logs/optuna_seq_refine_${step_job_id}.log"
+ state_file="logs/${step_job_id}_refine_state.json"
+
min_trades_arg=$("$PY" -c "from kis_trader.backtest.optuna_common import resolve_optuna_min_trades; print(resolve_optuna_min_trades('${START}', '${END}', '${strat}')['min_trades'])")
{
echo ""
- echo "-------- [$strat${entry_mode:+/$entry_mode}${sl_mode:+/$sl_mode}${bo_extra:+/$bo_extra}] START $(date -Is) study=$study univ=$UNIVERSE_HISTORY_SOURCE min_trades=$min_trades_arg --------"
+ echo "-------- [$strat${entry_mode:+/$entry_mode}${sl_mode:+/$sl_mode}${bo_extra:+/$bo_extra}] START $(date -Is) 1·2차 step=$step_job_id --------"
} | tee -a "$MASTER"
- echo "$log" > "logs/optuna_${strat}_tpe_latest.logpath"
- echo "$study" > "logs/optuna_${strat}_tpe_latest.study"
- # 웹 진행률: 전역 latest.study 가 이전 전략에 남으면 바가 1번에서 멈춤 → 잡별 파일
+
if [[ -n "${OPTUNA_SEQ_ACTIVE_FILE:-}" ]]; then
- if [[ "$strat" == "breakout" && -n "$bo_extra" ]]; then
- extra="$bo_extra"
- else
- extra="${entry_mode:-${sl_mode:-}}"
- fi
{
echo "strategy=$strat"
- echo "study=$study"
echo "entry_mode=${entry_mode:-}"
echo "sl_mode=${sl_mode:-}"
echo "ob_mode=${ob_mode:-}"
echo "extra=${extra}"
+ echo "refine_state_path=$state_file"
} > "$OPTUNA_SEQ_ACTIVE_FILE"
fi
set +e
- set +e
- local cmd_args=(
+ local refine_args=(
+ --job-id "$step_job_id"
--strategy "$strat"
--mode "$MODE"
--start "$START"
--end "$END"
--trials "$TRIALS"
- --min_trades "$min_trades_arg"
- --min_win_rate "$MIN_WIN_RATE"
- --min_pf "$MIN_PF"
- --orderbook-filter "$([[ "$strat" == "breakout" ]] && echo "$ob_mode" || echo off)"
- --no-progress
- --study-name "$study"
--sort-by "$sort_by"
+ --min-trades "$min_trades_arg"
--universe-history-source "$UNIVERSE_HISTORY_SOURCE"
)
if [[ -n "${STUDY_TRIALS:-}" && "${STUDY_TRIALS}" != "0" ]]; then
- cmd_args+=(--study-trials "$STUDY_TRIALS")
+ refine_args+=(--study-trials "$STUDY_TRIALS")
fi
if [[ "$strat" == "tail" && -n "$entry_mode" ]]; then
- cmd_args+=(--entry-mode "$entry_mode")
+ refine_args+=(--entry-mode "$entry_mode")
fi
if [[ "$strat" == "breakout" && -n "$sl_mode" ]]; then
- cmd_args+=(--sl-mode "$sl_mode")
+ refine_args+=(--sl-mode "$sl_mode" --ob-mode "$ob_mode")
+ fi
+ if [[ -n "${CANDLE_SOURCE:-}" ]]; then
+ refine_args+=(--candle-source "$CANDLE_SOURCE")
fi
-
if [[ -n "${TICK_SOURCE:-}" ]]; then
- cmd_args+=("--tick-source" "$TICK_SOURCE")
+ refine_args+=(--tick-source "$TICK_SOURCE")
fi
if [[ -n "${OB_SOURCE:-}" ]]; then
- cmd_args+=("--ob-source" "$OB_SOURCE")
+ refine_args+=(--ob-source "$OB_SOURCE")
fi
- "$PY" -u kis_trader/backtest/param_search_optuna.py "${cmd_args[@]}" >"$log" 2>&1
+ "$PY" -u kis_trader/backtest/optuna_mode_refine_runner.py "${refine_args[@]}" >"$refine_log" 2>&1
local rc=$?
set -e
+ local result_json briefing_md
+ result_json=""
+ briefing_md=""
+ if [[ -f "$state_file" ]]; then
+ result_json=$("$PY" -c "import json; print(json.load(open('${state_file}')).get('result_json') or '')" 2>/dev/null || true)
+ if [[ -n "$result_json" && -f "$result_json" ]]; then
+ echo "OPTUNA_RESULT_JSON=$result_json" | tee -a "$MASTER"
+ briefing_md="${result_json%.json}.briefing.md"
+ if [[ -f "$briefing_md" ]]; then
+ echo "OPTUNA_BRIEFING_MD=$briefing_md" | tee -a "$MASTER"
+ fi
+ fi
+ fi
+ if [[ -z "$result_json" ]]; then
+ grep -E 'OPTUNA_RESULT_JSON=' "$refine_log" | tail -n 1 | tee -a "$MASTER" || true
+ fi
+
{
echo "-------- [$strat${entry_mode:+/$entry_mode}${sl_mode:+/$sl_mode}${bo_extra:+/$bo_extra}] END rc=$rc $(date -Is) --------"
- echo "LOG=$log"
- grep -E 'OPTUNA_RESULT_JSON=|OPTUNA_BRIEFING_MD=|Best trial|optuna_best|❌|KeyError|Traceback' "$log" | tail -n 24 || true
+ echo "REFINE_LOG=$refine_log"
+ echo "STATE=$state_file"
+ grep -E 'OPTUNA_RESULT_JSON=|OPTUNA_BRIEFING_MD=|✅ 1·2차|❌ 1·2차|phase1|phase2|Traceback' "$refine_log" | tail -n 24 || true
} | tee -a "$MASTER"
if [[ "$rc" -ne 0 ]]; then
- echo "⚠️ [$strat] 실패 rc=$rc — 다음 전략 계속" | tee -a "$MASTER"
+ echo "⚠️ [$strat] 1·2차 실패 rc=$rc — 다음 전략 계속" | tee -a "$MASTER"
fi
return 0
}
diff --git a/static/css/backtest.css b/static/css/backtest.css
index 62941a1..16632d0 100644
--- a/static/css/backtest.css
+++ b/static/css/backtest.css
@@ -382,6 +382,39 @@
overflow: auto;
}
+ .opt-period-banner {
+ border: 1px solid var(--border);
+ font-size: 12px;
+ }
+ .opt-period-banner.opt-period-ok {
+ background: rgba(63, 185, 80, 0.08);
+ border-color: rgba(63, 185, 80, 0.35);
+ }
+ .opt-period-banner.opt-period-warn {
+ background: rgba(210, 153, 34, 0.12);
+ border-color: rgba(210, 153, 34, 0.55);
+ }
+ .opt-period-badge {
+ display: inline-block;
+ padding: 2px 8px;
+ border-radius: 4px;
+ border: 1px solid var(--border);
+ background: rgba(255, 255, 255, 0.04);
+ font-size: 11px;
+ white-space: nowrap;
+ }
+ .opt-period-badge b { font-weight: 600; color: var(--text); }
+ .opt-period-badge-form { border-color: #58a6ff; }
+ .opt-period-badge-master { border-color: #8b949e; }
+ .opt-period-badge-p1 { border-color: #3fb950; }
+ .opt-period-badge-p2 { border-color: #d29922; }
+ .opt-period-badge-active { border-color: #bc8cff; }
+ .opt-period-badge-warn {
+ background: rgba(248, 81, 73, 0.12);
+ border-color: rgba(248, 81, 73, 0.55);
+ }
+ .opt-period-cell-warn { color: #d29922; }
+
/* 오늘 운영 — 조건검색 이력(키움/LS) */
.dash-univ-table .dash-univ-src-kiwoom { color: #58a6ff; font-weight: 600; }
.dash-univ-table .dash-univ-src-kis { color: #3fb950; font-weight: 600; }
diff --git a/static/js/backtest.js b/static/js/backtest.js
index 4f335d2..371b96d 100644
--- a/static/js/backtest.js
+++ b/static/js/backtest.js
@@ -8339,6 +8339,7 @@ function optunaSetNav(job) {
const postBusy = phase === 'postprocess' || (st === 'running' && pct >= 99.9 && !post.ready && post.expect_ob !== false);
const finalizeBusy = phase === 'finalize';
const label = (job.label || job.strategy || 'Optuna') +
+ (prog.refine_phase === 'phase1' ? ' · 1차' : (prog.refine_phase === 'phase2' ? ' · 2차' : '')) +
(postBusy ? ' 후처리' : (finalizeBusy ? ' 저장중' : (st === 'running' ? ' 실행중' : (st === 'done' ? ' 완료' : (st === 'error' ? ' 오류' : '')))));
const done = prog.trials_done != null ? prog.trials_done : '?';
const tot = prog.trials_total != null ? prog.trials_total : '?';
@@ -8421,12 +8422,16 @@ function optunaRenderCompare(sum) {
}
}
optunaRenderModeCombo(sum);
+ optunaRenderModeTop10(sum);
optunaRenderTrailRec(sum);
optunaRenderPostprocess(sum);
optunaRenderOverfitBadges(sum);
optunaRenderOverfit(sum);
+ optunaRenderParamDistChart(sum);
}
+const OPTUNA_CARD_TOP_N = 5;
+
function optunaRenderModeCombo(sum) {
const el = $('opt_mode_combo_body');
const m = sum && sum.mode_combo_summary;
@@ -8456,17 +8461,106 @@ function optunaRenderModeCombo(sum) {
el.innerHTML =
`<div class="text-muted" style="font-size:11px">` +
`Top${m.top_n != null ? m.top_n : 'N'} 축최빈 조립` +
- (m.pool_size != null ? ` · pool ${m.pool_size}` : '') +
+ (m.pool_kind ? ` · pool=${m.pool_kind}` : '') +
+ (m.pool_size != null ? ` · ${m.pool_size}건` : '') +
(vsLine ? ` · ${vsLine}` : '') +
` · 아래 표=사후합격과 <b>같은 열</b>(안정·과적합 포함). trial 번호는 조립이라 없음.` +
`</div>` +
(m.note ? `<div class="text-muted" style="font-size:11px">${m.note}</div>` : '') +
`<div class="mt-1">` +
- `<button type="button" class="btn btn-sm btn-outline-secondary py-0" onclick="optunaSelectPostprocess('mode',1)">mode 상세(후처리)</button>` +
- ` <button type="button" class="btn btn-sm btn-outline-warning py-0 ms-1" id="opt_btn_apply_mode_card" onclick="optunaApplyUpto('mode',1,'base')">mode 타점 DB적용</button>` +
+ `<button type="button" class="btn btn-sm btn-outline-warning py-0 ms-0" id="opt_btn_apply_mode_card" onclick="optunaApplyUpto('mode',1,'base')">mode 타점 DB적용</button>` +
`</div>`;
}
+function optunaRenderModeTop10(sum) {
+ const metaEl = $('opt_mode_top_meta');
+ const meta = (sum && sum.mode_consensus_meta) || {};
+ if (metaEl) {
+ if (!sum) {
+ metaEl.textContent = '—';
+ } else if (meta.mode_pool_size != null || meta.note) {
+ metaEl.innerHTML =
+ `pool ${meta.mode_pool_kind || 'positive'}`
+ + (meta.mode_pool_size != null ? ` · ${meta.mode_pool_size}건` : '')
+ + (meta.mode_params_keys != null ? ` · 축 ${meta.mode_params_keys}개` : '')
+ + (meta.note ? ` · <span class="text-muted">${meta.note}</span>` : '')
+ + (meta.scoring ? ` · <span class="text-muted">${meta.scoring}</span>` : '');
+ } else {
+ metaEl.textContent = 'mode Top10 — 완료 후 표시';
+ }
+ }
+ optunaFillModeTopTbody(
+ 'opt_top5_mode_top_tbody',
+ (sum && sum.top5_consensus) || [],
+ 'consensus',
+ sum ? 'mode Top10 없음 (pool 0 · grid/results 확인)' : '완료 후 표시',
+ );
+ optunaRenderModeBandTop3(sum, window._optunaLastJob || null);
+}
+
+function optunaDailyAvgPnlCell(r) {
+ const v = (r.period_daily_avg_pnl != null) ? r.period_daily_avg_pnl : r.daily_pnl_mean;
+ if (v == null) return '<td class="text-end text-muted">—</td>';
+ const n = Number(v);
+ const cls = n >= 0 ? 'text-pnl-pos' : 'text-pnl-neg';
+ return `<td class="text-end ${cls}" title="총손익÷기간거래일 (없으면 활성일 평균)">${optunaFmtNum(v)}</td>`;
+}
+
+function optunaDailyAvgPctCell(r) {
+ let p = r.period_daily_avg_pct;
+ if (p == null && r.daily_avg_pct != null) p = r.daily_avg_pct;
+ if (p == null && r.bot_pct != null && r.n_period_trading_days > 0) {
+ p = Number(r.bot_pct) / Number(r.n_period_trading_days);
+ }
+ if (p == null) return '<td class="text-end text-muted">—</td>';
+ const n = Number(p);
+ const cls = n >= 0 ? 'text-pnl-pos' : 'text-pnl-neg';
+ const sign = n > 0 ? '+' : '';
+ return `<td class="text-end ${cls}" title="총수익률(운용한도)÷기간거래일">${sign}${optunaFmtNum(n, 3)}%</td>`;
+}
+
+function optunaModeTopTableRow(r, source) {
+ const rank = r.rank || 1;
+ const src = source;
+ const applyCls = 'btn-outline-success';
+ const cm = r.consensus_match_pct;
+ const cmN = r.consensus_match_n;
+ const cmOf = r.consensus_match_of;
+ const cmCell = (cm != null && cmOf != null)
+ ? `${optunaFmtNum(cm, 1)}%<div class="text-muted" style="font-size:10px">${cmN}/${cmOf}축 밴드</div>`
+ : '—';
+ return `<tr>
+ <td>${rank}</td>
+ <td>#${r.optuna_trial_number ?? '—'}</td>
+ <td class="text-end fw-semibold">${cmCell}</td>
+ <td class="text-end">${optunaFmtNum(r.total_trades, 0)}</td>
+ <td class="text-end">${optunaFmtNum(r.win_rate, 1)}%</td>
+ <td class="text-end">${optunaFmtNum(r.pf, 2)}</td>
+ <td class="text-end ${Number(r.total_pnl) >= 0 ? 'text-pnl-pos' : 'text-pnl-neg'}">${optunaFmtNum(r.total_pnl)}</td>
+ ${optunaDailyAvgPnlCell(r)}
+ ${optunaDailyAvgPctCell(r)}
+ <td style="font-size:11px;min-width:148px;white-space:normal">${optunaObWhipCell(r)}</td>
+ <td class="text-end">${r.n_losing_days != null ? optunaFmtNum(r.n_losing_days, 0) : '—'}</td>
+ <td class="text-end ${Number(r.worst_day_pnl) >= 0 ? 'text-pnl-pos' : 'text-pnl-neg'}">${r.worst_day_pnl != null ? optunaFmtNum(r.worst_day_pnl) : '—'}</td>
+ <td class="text-end">${optunaStableScoreCell(r)}</td>
+ <td class="text-end">${optunaOverfitCell(r)}</td>
+ <td>
+ <button class="btn btn-sm btn-outline-secondary py-0" onclick="optunaShowCandidate('${src}',${rank})">보기</button>
+ <button class="btn btn-sm ${applyCls} py-0" onclick="optunaApply('${src}',${rank})">적용</button>
+ </td>
+ </tr>`;
+}
+
+function optunaFillModeTopTbody(tbodyId, rows, source, emptyMsg) {
+ const tb = $(tbodyId);
+ if (!tb) return;
+ if (!rows || !rows.length) {
+ tb.innerHTML = `<tr><td colspan="15" class="text-muted">${emptyMsg}</td></tr>`;
+ return;
+ }
+ tb.innerHTML = rows.map((r) => optunaModeTopTableRow(r, source)).join('');
+}
+
function optunaRenderOverfit(sum) {
const scoreEl = $('opt_overfit_score');
const facTb = $('opt_overfit_factors_tbody');
@@ -8535,6 +8629,184 @@ function optunaRenderOverfit(sum) {
if (noteEl) noteEl.textContent = d.note || '—';
}
+function optunaRefinePhaseLabel(job) {
+ const sy = String((job && job.study_name) || '');
+ const sh = String((job && job.study_short) || '');
+ if (sy.includes('refine2') || sh === 'refine2') return '2차 TPE';
+ if (sy.includes('refine1') || sh === 'refine1') return '1차 TPE';
+ const ph = (job && job.progress && job.progress.refine_phase) || '';
+ if (ph === 'phase2') return '2차 TPE';
+ if (ph === 'phase1') return '1차 TPE';
+ return '';
+}
+
+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;
+ const bdr = showBorder !== false ? ' border-bottom border-secondary' : '';
+ return `<div class="d-flex flex-wrap align-items-start gap-2 py-1${bdr}">
+ <span class="fw-semibold">#${i} trial #${t.optuna_trial_number ?? '—'}</span>
+ <span>PnL <b class="${Number(t.total_pnl) >= 0 ? 'text-pnl-pos' : 'text-pnl-neg'}">${Number(t.total_pnl || 0).toLocaleString()}</b>원</span>
+ ${dAvg != null ? `<span>일평균 ${Number(dAvg).toLocaleString()}원</span>` : ''}
+ ${dPct != null ? `<span>일평균 ${Number(dPct) >= 0 ? '+' : ''}${Number(dPct).toFixed(3)}%</span>` : ''}
+ <span>WR ${Number(t.win_rate || 0).toFixed(1)}% · PF ${Number(t.pf || 0).toFixed(2)} · ${Number(t.total_trades || 0)}건</span>
+ <span>${optunaObWhipCell(t)}</span>
+ <button type="button" class="btn btn-sm btn-outline-secondary py-0" onclick="optunaShowCandidate('gated',${rank})">보기</button>
+ <button type="button" class="btn btn-sm btn-outline-primary py-0" onclick="optunaBacktestFromCandidate('gated',${rank})">백테</button>
+ <button type="button" class="btn btn-sm btn-outline-success py-0" onclick="optunaApply('gated',${rank})">적용</button>
+ </div>`;
+}
+
+function optunaBuildGatedTop3Html(rows, opts) {
+ const o = opts || {};
+ const n = o.topN || OPTUNA_CARD_TOP_N;
+ const list = (rows || []).slice(0, n);
+ if (!list.length) return '';
+ const pr = o.periodRange ? ` · 기간 <b>${o.periodRange}</b>` : '';
+ const phase = o.phaseLabel || '사후합격';
+ const note = o.running
+ ? ' <span class="text-warning">(진행 중 · 사후합격 미확정)</span>'
+ : '';
+ let html = `<div class="mb-1 fw-semibold text-success">${phase} Top${n}${pr}${note}</div>`;
+ list.forEach((t, i) => {
+ html += optunaFormatGatedTopRowHtml(t, i + 1, i < list.length - 1);
+ });
+ return html;
+}
+
+function optunaRenderGatedTop3Card(sum, job) {
+ const body = $('opt_gated_top3_body');
+ const title = $('opt_gated_top3_title');
+ if (!body) return;
+ const phase = optunaRefinePhaseLabel(job);
+ const phaseTxt = phase ? `${phase} 사후합격 Top${OPTUNA_CARD_TOP_N}` : `사후합격 Top${OPTUNA_CARD_TOP_N}`;
+ if (title) {
+ title.innerHTML = `${phaseTxt} <small class="text-muted">DB 적용 후보 · 일평균(원/%) · 익절/손절 비교</small>`;
+ }
+ const pr = (job && job.start && job.end)
+ ? optunaFmtPeriodShort(job.start, job.end)
+ : '';
+ if (job && job.status === 'running' && job.live_summary) {
+ const ls = job.live_summary;
+ const ph = ls.refine_phase === 'phase2' ? '2차 TPE' : (ls.refine_phase === 'phase1' ? '1차 TPE' : phase);
+ const rows = (ls.top3_gated && ls.top3_gated.length)
+ ? ls.top3_gated
+ : (ls.top3_learn || []);
+ if (rows.length) {
+ body.innerHTML = optunaBuildGatedTop3Html(rows, {
+ phaseLabel: ph ? `${ph} learn` : 'learn',
+ periodRange: ls.period_range || pr,
+ running: !ls.top3_gated || !ls.top3_gated.length,
+ });
+ return;
+ }
+ }
+ const gated = (sum && sum.top5_gated) || [];
+ if (!gated.length) {
+ const done = job && job.status === 'done';
+ body.innerHTML = done
+ ? '<span class="text-muted">사후합격 후보 없음</span>'
+ : '<span class="text-muted">완료 후 표시</span>';
+ return;
+ }
+ body.innerHTML = optunaBuildGatedTop3Html(gated, {
+ phaseLabel: phase ? `${phase} 사후합격` : '사후합격',
+ periodRange: pr,
+ running: false,
+ });
+}
+
+function optunaRenderModeBandTop3(sum, job) {
+ const el = $('opt_mode_band_top3');
+ if (!el) return;
+ const rows = (sum && sum.top5_consensus) || [];
+ if (!rows.length) {
+ el.innerHTML = `<span class="text-muted">mode 밴드 근접 Top${OPTUNA_CARD_TOP_N} 없음</span>`;
+ return;
+ }
+ const pr = (job && job.start && job.end)
+ ? optunaFmtPeriodShort(job.start, job.end)
+ : '';
+ const pool = (sum.mode_consensus_meta && sum.mode_consensus_meta.mode_pool_size) || null;
+ el.innerHTML =
+ optunaBuildModeTop3Html(rows.slice(0, OPTUNA_CARD_TOP_N), {
+ periodRange: pr,
+ poolSize: pool,
+ topN: OPTUNA_CARD_TOP_N,
+ title: `mode 밴드 근접 Top${OPTUNA_CARD_TOP_N} (분포 참고 · DB 1순위 아님)`,
+ });
+}
+
+function optunaRenderParamDistChart(sum) {
+ const el = $('opt_param_dist_chart');
+ if (!el) return;
+ const d = sum && sum.overfit_diagnostics;
+ const dist = (d && d.threshold_distribution) || [];
+ if (!dist.length) {
+ const done = window._optunaLastJob && window._optunaLastJob.status === 'done';
+ el.innerHTML = done
+ ? '<span class="text-muted">분포 데이터 없음</span>'
+ : '<span class="text-muted">완료 후 표시</span>';
+ return;
+ }
+ const rows = dist.filter((r) => {
+ if (r.p25 == null || r.p75 == null) return false;
+ const p = String(r.param || '');
+ return p && !p.endsWith('_mode') && p !== 'sl_mode';
+ });
+ const pri = ['tp_pct', 'sl_pct', 'trail_pct', 'trail_arm_pct', 'max_daily_chg', 'vol_mult'];
+ rows.sort((a, b) => {
+ const ia = pri.indexOf(String(a.param || ''));
+ const ib = pri.indexOf(String(b.param || ''));
+ const pa = ia >= 0 ? ia : 999;
+ const pb = ib >= 0 ? ib : 999;
+ if (pa !== pb) return pa - pb;
+ return String(a.param || '').localeCompare(String(b.param || ''));
+ });
+ if (!rows.length) {
+ el.innerHTML = '<span class="text-muted">수치형 분포 없음</span>';
+ return;
+ }
+ const poolNote = d.threshold_pool
+ ? `<div class="text-muted mb-2" style="font-size:11px">표본: ${d.threshold_pool} · n=${d.threshold_pool_n || 0}</div>`
+ : '';
+ 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 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 += `<div style="position:absolute;left:${pct(med).toFixed(1)}%;top:0;width:2px;height:100%;background:#3fb950" title="median ${med}"></div>`;
+ }
+ if (mode != null && !Number.isNaN(mode)) {
+ marks += `<div style="position:absolute;left:${pct(mode).toFixed(1)}%;top:3px;width:8px;height:8px;background:#f0883e;border-radius:50%" title="mode ${mode}"></div>`;
+ }
+ const l = pct(p25).toFixed(1);
+ const w = Math.max(1, pct(p75) - pct(p25)).toFixed(1);
+ return `<div class="mb-2">
+ <div class="d-flex justify-content-between align-items-center">
+ <code style="font-size:11px">${r.param}</code>
+ <span class="text-muted" style="font-size:10px">mode ${fmt(r.mode)} (${share})</span>
+ </div>
+ <div style="position:relative;height:14px;background:#21262d;border-radius:4px;margin:2px 0">
+ <div style="position:absolute;left:${l}%;width:${w}%;height:100%;background:rgba(56,139,253,0.4);border-radius:3px"></div>
+ ${marks}
+ </div>
+ <div class="text-muted" style="font-size:10px">p25 ${fmt(p25)} · med ${fmt(med)} · p75 ${fmt(p75)}</div>
+ </div>`;
+ }).join('');
+ el.innerHTML = poolNote + bars;
+}
+
function optunaRenderTrailRec(sum) {
const body = $('opt_daily_trail_rec_body');
if (!body) return;
@@ -8647,15 +8919,7 @@ function optunaObWhipCell(r) {
function optunaRankTableRow(r, source) {
const rank = r.rank || 1;
const src = source;
- const busy = optunaPostBusy(window._optunaLastJob);
- const anchors = (((window._optunaLastSummary || {}).postprocess_topn || {}).postprocess_by_anchor) || [];
- const hasLearnAnchor = anchors.some((a) => a && a.role === 'learn');
- // 학습 Top「상세」: learn 후처리 앵커가 있을 때만 (gated 비면 learn 폴백)
- const detailBtn = (src === 'learn' && !hasLearnAnchor)
- ? ''
- : (busy
- ? '<button class="btn btn-sm btn-outline-secondary py-0" disabled title="후처리 끝나야 표가 채워집니다">상세</button>'
- : `<button class="btn btn-sm btn-outline-primary py-0" onclick="optunaSelectPostprocess('${src}',${rank})">상세</button>`);
+ const detailBtn = '';
const applyCls = src === 'stable' ? 'btn-outline-info' : 'btn-outline-success';
return `<tr>
<td>${rank}</td>
@@ -8664,7 +8928,8 @@ function optunaRankTableRow(r, source) {
<td class="text-end">${optunaFmtNum(r.win_rate, 1)}%</td>
<td class="text-end">${optunaFmtNum(r.pf, 2)}</td>
<td class="text-end ${Number(r.total_pnl) >= 0 ? 'text-pnl-pos' : 'text-pnl-neg'}">${optunaFmtNum(r.total_pnl)}</td>
- <td class="text-end ${Number(r.period_daily_avg_pnl != null ? r.period_daily_avg_pnl : r.daily_pnl_mean) >= 0 ? 'text-pnl-pos' : 'text-pnl-neg'}" title="총손익÷기간거래일 (없으면 활성일 평균)">${(r.period_daily_avg_pnl != null || r.daily_pnl_mean != null) ? optunaFmtNum(r.period_daily_avg_pnl != null ? r.period_daily_avg_pnl : r.daily_pnl_mean) : '—'}</td>
+ ${optunaDailyAvgPnlCell(r)}
+ ${optunaDailyAvgPctCell(r)}
<td style="font-size:11px;min-width:148px;white-space:normal">${optunaObWhipCell(r)}</td>
<td class="text-end">${r.n_losing_days != null ? optunaFmtNum(r.n_losing_days, 0) : '—'}</td>
<td class="text-end ${Number(r.worst_day_pnl) >= 0 ? 'text-pnl-pos' : 'text-pnl-neg'}">${r.worst_day_pnl != null ? optunaFmtNum(r.worst_day_pnl) : '—'}</td>
@@ -8672,6 +8937,7 @@ function optunaRankTableRow(r, source) {
<td class="text-end">${optunaOverfitCell(r)}</td>
<td>
<button class="btn btn-sm btn-outline-secondary py-0" onclick="optunaShowCandidate('${src}',${rank})">보기</button>
+ ${src !== 'mode' ? `<button class="btn btn-sm btn-outline-primary py-0" onclick="optunaBacktestFromCandidate('${src}',${rank})">백테</button>` : ''}
${detailBtn}
<button class="btn btn-sm ${applyCls} py-0" onclick="optunaApply('${src}',${rank})">적용</button>
</td>
@@ -8682,7 +8948,7 @@ function optunaFillRankTbody(tbodyId, rows, source, emptyMsg) {
const tb = $(tbodyId);
if (!tb) return;
if (!rows || !rows.length) {
- tb.innerHTML = `<tr><td colspan="13" class="text-muted">${emptyMsg}</td></tr>`;
+ tb.innerHTML = `<tr><td colspan="14" class="text-muted">${emptyMsg}</td></tr>`;
return;
}
tb.innerHTML = rows.map((r) => optunaRankTableRow(r, source)).join('');
@@ -9062,12 +9328,8 @@ function _optunaBaseCell(a) {
+ (bits.length ? `<div style="font-size:12px;line-height:1.45">${bits.join(' · ')}</div>` : '');
}
-function optunaSelectPostprocess(source, rank) {
- window._optunaPostSel = { source: source || 'gated', rank: rank || 1 };
- _optunaPostFp = ''; // 선택 바뀌면 표 다시 그림
- optunaRenderPostprocess(window._optunaLastSummary || null);
- const box = $('opt_postprocess_rec');
- if (box) box.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
+function optunaSelectPostprocess(_source, _rank) {
+ // 호가 8방 UI 제거 — no-op
}
function optunaPostprocessFingerprint(sum) {
@@ -9087,223 +9349,8 @@ function optunaPostprocessFingerprint(sum) {
].join('|');
}
-function optunaRenderPostprocess(sum) {
- const cards = $('opt_postprocess_cards');
- const consEl = $('opt_postprocess_consensus');
- const job = window._optunaLastJob || null;
- const finish = () => optunaSyncRerunButton(sum, job);
- if (!cards) { finish(); return; }
- if (!sum) {
- cards.innerHTML = '<div class="text-muted">사후합격 또는 안정 Top5에서 「상세」를 누르면 이 표가 바뀝니다.</div>';
- if (consEl) consEl.textContent = '합의: 완료 후 표시';
- _optunaPostFp = '';
- finish();
- return;
- }
- // 폴링 재진입: 내용·선택이 같으면 DOM 유지 (스크롤 점프 방지)
- const fp = optunaPostprocessFingerprint(sum);
- if (fp && fp === _optunaPostFp && cards.innerHTML && cards.innerHTML.length > 80) {
- finish();
- return;
- }
- const yWin = window.scrollY || window.pageYOffset || 0;
- const box = $('opt_postprocess_rec');
- const zone = $('opt_postprocess_cards');
- const yBox = box ? box.scrollTop : 0;
- const yZone = zone ? zone.scrollTop : 0;
- try {
- const ob8prev = zone && zone.querySelector('.opt-ob8-zone');
- if (ob8prev) window._optunaOb8Scroll = ob8prev.scrollTop;
- } catch (e0) { /* ignore */ }
- const topn = sum.postprocess_topn;
- const anchors = (topn && topn.postprocess_by_anchor) || [];
- const cons = (topn && topn.postprocess_consensus) || {};
- if (consEl) {
- const cc = cons.combo || {};
- const cws = cons.whipsaw || {};
- const ctr = cons.trail || {};
- if (cc.ok) {
- const rs = cc.recommended_stats || {};
- const pnl = rs.pnl != null ? Number(rs.pnl).toLocaleString() : '—';
- const wr = rs.win_rate != null ? Number(rs.win_rate).toFixed(1) + '%' : '—';
- consEl.innerHTML =
- '<b>합의(gated+mode, 실매 제외)</b>'
- + `<div>호가 방: <b>${optunaObComboLabel(cc.combo_id, cc.label)}</b>`
- + ` · ${cc.n || 0}앵커 · PnL ${pnl} · WR ${wr}`
- + ` · 휩쏘: ${cws.ok ? '있음' : '없음'}`
- + ` · 트레일: ${ctr.ok ? ('ARM ' + Number(ctr.arm_krw || 0).toLocaleString()) : '없음'}</div>`
- + (cons.note ? `<div class="text-muted" style="font-size:10px">${cons.note}</div>` : '');
- } else {
- const ce = cons.entry || {};
- const cx = cons.exit || {};
- const cs = cons.stop || {};
- const kind = optunaPool8wayKind(anchors);
- const st0 = optunaOb8wayState(((anchors.find((x) => x.role === 'gated') || {}).orderbook));
- const ran = !!(topn && topn.run_ob_whipsaw);
- const head = (kind === 'legacy' && !ran)
- ? '호가: 8방 미산출(구JSON)'
- : ('호가: 8방 미산출(' + (optunaObReasonKo(st0) || 'TPE 없음') + ')');
- const note = (kind === 'failed' || (kind === 'legacy' && ran))
- ? ('합의=8방 유효 방 없음(' + (optunaObReasonKo(st0) || '') + '). 축분리 median 폴백. 호가스냅이 늘지 않으면 재실행해도 동일.')
- : (cons.note || '');
- consEl.innerHTML =
- '<b>합의(gated+mode, 실매 제외)</b>'
- + `<div>${head} · 진입 ${ce.ok ? '있음' : '없음'} · 익절 ${cx.ok ? '있음' : '없음'} · 손절 ${cs.ok ? '있음' : '없음'}`
- + ` · 휩쏘: ${cws.ok ? '있음' : '없음'} · 트레일: ${ctr.ok ? ('ARM ' + Number(ctr.arm_krw || 0).toLocaleString()) : '없음'}</div>`
- + (note ? `<div class="text-muted" style="font-size:10px">${note}</div>` : '');
- }
- }
- const strat = String((sum && sum.strategy) || (job && job.strategy) || '').toLowerCase();
- const canExitStop = strat === 'momentum' || strat === 'breakout';
- const whipSkip = strat === 'tail' || strat === 'breakout' || strat === 'short';
- if (!anchors.length) {
- cards.innerHTML = '<div class="text-muted">TopN 후처리 없음(구 JSON). 「이 잡 후처리 재실행」으로 축별 숫자를 채우세요.</div>'
- + '<div class="mt-1"><button type="button" class="btn btn-sm btn-warning py-0" onclick="optunaRerunPostprocess()">이 잡 후처리 재실행</button></div>';
- finish();
- return;
- }
- const sel = window._optunaPostSel || { source: 'gated', rank: 1 };
- let src = sel.source || 'gated';
- let rk = sel.rank || 1;
- let a = null;
- if (src === 'mode') {
- a = anchors.find((x) => x.role === 'mode') || null;
- } else if (src === 'stable') {
- a = anchors.find((x) => x.role === 'stable' && Number(x.rank) === Number(rk)) || null;
- } else if (src === 'learn') {
- a = anchors.find((x) => x.role === 'learn' && Number(x.rank) === Number(rk)) || null;
- } else {
- a = anchors.find((x) => x.role === 'gated' && Number(x.rank) === Number(rk)) || null;
- }
- // gated 비면 learn# → mode 순으로 폴백 (호가방이 학습 후보에서 보이게)
- let fallbackNote = '';
- if (!a) {
- const learnA = anchors.find((x) => x.role === 'learn' && Number(x.rank) === 1)
- || anchors.find((x) => x.role === 'learn') || null;
- const modeA = anchors.find((x) => x.role === 'mode') || null;
- if (learnA) {
- a = learnA;
- src = 'learn';
- rk = Number(learnA.rank) || 1;
- fallbackNote =
- '<div class="text-warning mb-1" style="font-size:11px">'
- + '사후합격(gated) 0건 → <b>학습 Top 후처리(learn#)</b> 표시. '
- + 'WR/PF 사후게이트와 무관하게 호가 8방을 돌린 결과입니다. DB적용은 여전히 gated 우선.'
- + '</div>';
- window._optunaPostSel = { source: 'learn', rank: rk };
- } else if (modeA) {
- a = modeA;
- src = 'mode';
- rk = 1;
- fallbackNote =
- '<div class="text-warning mb-1" style="font-size:11px">'
- + '사후합격·학습 앵커 없음 → <b>mode/live</b>만 표시.'
- + '</div>';
- window._optunaPostSel = { source: 'mode', rank: 1 };
- }
- }
- const live = anchors.find((x) => x.role === 'live') || null;
- const ofKey = src === 'stable' ? 'top5_stable' : (src === 'learn' ? 'top5_learn' : 'top5_gated');
- const ofRow = ((sum && sum[ofKey]) || []).find((x) => Number(x.rank) === Number(rk)) || null;
- const ofPct = (ofRow && ofRow.overfit_risk_pct != null)
- ? ofRow.overfit_risk_pct
- : ((topn && topn.apply_overfit_pct != null) ? topn.apply_overfit_pct : sum.apply_overfit_pct);
- const verd = (ofRow && (ofRow.overfit_verdict_ui || ofRow.overfit_verdict))
- || (topn && (topn.apply_overfit_verdict_ui || topn.apply_overfit_verdict)) || '';
- if (!a) {
- const gatedN = ((sum && sum.top5_gated) || []).length;
- const stableN = ((sum && sum.top5_stable) || []).length;
- cards.innerHTML =
- '<div class="text-muted">이 순위 후처리 앵커 없음.</div>'
- + `<div class="text-muted" style="font-size:11px">gated=${gatedN} · stable=${stableN} · `
- + `앵커=${anchors.map((x) => x.id || x.role).join(',') || '없음'}. `
- + '사후합격·학습·안정 Top5「상세」또는 mode 를 선택하세요.</div>';
- finish();
- return;
- }
- if (fallbackNote) {
- cards.dataset.fallbackNote = '1';
- } else {
- delete cards.dataset.fallbackNote;
- }
- const renderOne = (row, opts) => {
- const isLive = !!(opts && opts.live);
- const rowSrc = isLive ? '' : (row.role === 'mode' ? 'mode' : (row.role === 'stable' ? 'stable' : (row.role === 'learn' ? 'learn' : 'gated')));
- const rowRk = row.rank || 1;
- const ob = row.orderbook || {};
- const whipBtn = () => {
- if (isLive || !rowSrc) return '<span class="text-muted">참고</span>';
- if (whipSkip) return '<span class="text-muted">해당없음</span>';
- // 구버튼: base+휩쏘 DB적용 (탐색은 후처리 재실행)
- const ws = row.whipsaw || {};
- if (ws && ws.ok === true) {
- return `<button class="btn btn-sm btn-outline-success py-0" title="000방 휩쏘 DB적용" onclick="optunaApplyUpto('${rowSrc}',${rowRk},'base+whip')">000 휩쏘적용</button>`;
- }
- return `<button class="btn btn-sm btn-outline-secondary py-0" disabled title="미산출 · 후처리 재실행 필요">000 휩쏘적용</button>`;
- };
- const baseBtn = () => {
- if (isLive || !rowSrc) return '<span class="text-muted">참고</span>';
- return `<button class="btn btn-sm btn-outline-success py-0" onclick="optunaApplyUpto('${rowSrc}',${rowRk},'base')">타점만(호가OFF)</button>`;
- };
- const wr = row.win_rate != null ? row.win_rate
- : (isLive
- ? (((row.orderbook || {}).orig_stats || {}).win_rate)
- : (ofRow && ofRow.win_rate));
- const rowForBase = Object.assign({}, row, { win_rate: wr });
- const title = isLive
- ? `${row.id} · 실매 참고 (적용 없음)`
- : `${row.id} · trial ${row.optuna_trial_number != null ? '#' + row.optuna_trial_number : '—'}`
- + ` · 과적합 가능도 ${ofPct != null ? ofPct : '—'}% · ${verd || ''}`;
- return `<div class="${isLive ? 'mt-2 text-muted' : ''}">
- <div class="fw-bold">${title}</div>
- <div class="text-muted" style="font-size:11px">과적합%=추정. ${row.note || ''} · 호가 방마다 휩쏘 TPE(통과 체결 기준)</div>
- <div class="mt-1" style="font-size:11px">차트 타점: ${_optunaBaseCell(rowForBase)} · ${baseBtn()}</div>
- ${_optunaObComboTable(ob, canExitStop, rowSrc, rowRk, isLive, row, whipSkip)}
- <div class="mt-1 table-responsive">
- <table class="table table-sm mb-0" style="font-size:11px">
- <thead><tr><th>참고</th><th>성적</th><th>적용</th></tr></thead>
- <tbody>
- <tr>
- <td>000 방 휩쏘(앵커)</td>
- <td>${_optunaAxisStatsHtml(row.whipsaw, row.whipsaw)}</td>
- <td>${whipBtn()}</td>
- </tr>
- </tbody>
- </table>
- </div>
- </div>`;
- };
- cards.innerHTML =
- (fallbackNote || '')
- + renderOne(a, { live: false })
- + (live ? renderOne(live, { live: true }) : '');
- document.querySelectorAll('#opt_top5_tbody tr').forEach((tr, i) => {
- tr.style.outline = (src === 'gated' && i + 1 === Number(rk)) ? '1px solid var(--accent)' : '';
- });
- document.querySelectorAll('#opt_top5_learn_tbody tr').forEach((tr, i) => {
- tr.style.outline = (src === 'learn' && i + 1 === Number(rk)) ? '1px solid var(--accent)' : '';
- });
- document.querySelectorAll('#opt_top5_stable_tbody tr').forEach((tr, i) => {
- tr.style.outline = (src === 'stable' && i + 1 === Number(rk)) ? '1px solid var(--accent)' : '';
- });
- // 폴링 재렌더 후 스크롤 복원 (호가8방 구역·창)
- try {
- if (box) box.scrollTop = yBox;
- if (zone) zone.scrollTop = yZone;
- const ob8 = zone && zone.querySelector('.opt-ob8-zone');
- if (ob8 && window._optunaOb8Scroll != null) ob8.scrollTop = window._optunaOb8Scroll;
- window.scrollTo(0, yWin);
- } catch (e) { /* ignore */ }
- _optunaPostFp = optunaPostprocessFingerprint(sum);
- // 다음 폴링용 — 구역 안 스크롤 기억 (fingerprint skip 시에도 유지)
- try {
- const ob8 = zone && zone.querySelector('.opt-ob8-zone');
- if (ob8) {
- if (window._optunaOb8Scroll != null) ob8.scrollTop = window._optunaOb8Scroll;
- ob8.onscroll = function () { window._optunaOb8Scroll = ob8.scrollTop; };
- }
- } catch (e2) { /* ignore */ }
- finish();
+function optunaRenderPostprocess(_sum) {
+ // 호가 8방·후처리 비교표 UI 제거 — no-op (백엔드 postprocess_topn 은 유지)
}
function optunaRenderTop5(sum) {
@@ -9412,6 +9459,7 @@ async function optunaShowCandidate(source, rank) {
(j.start && j.end ? ` · 기간 ${j.start}~${j.end}` : '') +
(daily ? `<div class="mt-1 text-muted">일별 PnL: ${daily}</div>` : '') +
` <button class="btn btn-sm btn-outline-primary py-0 ms-2" onclick="optunaFillFormFromCandidate('${src}',${rk})">폼에 반영</button>` +
+ ` <button class="btn btn-sm btn-outline-primary py-0 ms-1" onclick="optunaBacktestFromCandidate('${src}',${rk})">백테탭</button>` +
` <button class="btn btn-sm btn-outline-success py-0 ms-1" onclick="optunaApply('${src}',${rk})">이 후보 DB적용</button>`;
}
if (pre) {
@@ -9435,6 +9483,86 @@ async function optunaShowCandidate(source, rank) {
}
}
+function optunaFmtPeriodShort(start, end) {
+ const s = String(start || '').slice(0, 10);
+ const e = String(end || '').slice(0, 10);
+ return (s && e) ? `${s}~${e}` : (s || e || '—');
+}
+
+function optunaPeriodBadge(label, start, end, extraCls) {
+ const r = optunaFmtPeriodShort(start, end);
+ return `<span class="opt-period-badge ${extraCls || ''}" title="${label}">${label} <b>${r}</b></span>`;
+}
+
+function optunaRenderPeriodBanner(job) {
+ const el = $('opt_period_banner');
+ if (!el) return;
+ const pi = job && job.period_info;
+ const formS = ($('opt_start')?.value || '').trim().slice(0, 10);
+ const formE = ($('opt_end')?.value || '').trim().slice(0, 10);
+ const jobS = String((job && job.start) || '').slice(0, 10);
+ const jobE = String((job && job.end) || '').slice(0, 10);
+ if (!pi && !jobS) {
+ el.innerHTML = '';
+ el.className = 'opt-period-banner d-none mb-2 p-2 rounded small';
+ return;
+ }
+ const formMismatch = !!(formS && formE && jobS && jobE && (formS !== jobS || formE !== jobE));
+ const hasRefine = !!(pi && pi.has_refine);
+ const mismatch = !!(pi && pi.mismatch) || formMismatch;
+ el.className = 'opt-period-banner mb-2 p-2 rounded small ' + (mismatch ? 'opt-period-warn' : 'opt-period-ok');
+ let html = mismatch
+ ? '<div class="fw-semibold mb-1">⚠ 기간이 서로 다릅니다 — Top5·일평균·백테 비교 시 study별 기간을 확인하세요</div>'
+ : '<div class="fw-semibold mb-1">📅 백테 기간 (전 단계 동일)</div>';
+ html += '<div class="d-flex flex-wrap gap-1 align-items-center">';
+ if (formS && formE) {
+ const fm = formMismatch ? ' opt-period-badge-warn' : '';
+ html += optunaPeriodBadge('상단 폼', formS, formE, 'opt-period-badge-form' + fm);
+ }
+ if (jobS && jobE) {
+ html += optunaPeriodBadge('잡(메인)', jobS, jobE, 'opt-period-badge-master');
+ }
+ if (hasRefine && pi) {
+ if (pi.phase1 && pi.phase1.start) {
+ const c = (pi.phase1.start !== jobS || pi.phase1.end !== jobE) ? ' opt-period-badge-warn' : '';
+ html += optunaPeriodBadge('1차 TPE', pi.phase1.start, pi.phase1.end, 'opt-period-badge-p1' + c);
+ }
+ if (pi.phase2 && pi.phase2.study && pi.phase2.start) {
+ const c = (pi.phase2.start !== jobS || pi.phase2.end !== jobE) ? ' opt-period-badge-warn' : '';
+ html += optunaPeriodBadge('2차 TPE', pi.phase2.start, pi.phase2.end, 'opt-period-badge-p2' + c);
+ }
+ if (pi.active && pi.active.study && pi.active.start) {
+ html += optunaPeriodBadge('활성 study', pi.active.start, pi.active.end, 'opt-period-badge-active');
+ }
+ } else if (pi && pi.active && pi.active.study && pi.active.start) {
+ html += optunaPeriodBadge('study', pi.active.start, pi.active.end, 'opt-period-badge-active');
+ }
+ html += '</div>';
+ if (formMismatch) {
+ html += `<div class="small mt-1 text-warning">상단 폼(${formS}~${formE}) ≠ 선택 잡(${jobS}~${jobE}) — 새 잡 시작 시 폼 기간이 적용됩니다</div>`;
+ }
+ if (pi && pi.mismatch_notes && pi.mismatch_notes.length) {
+ html += `<div class="small mt-1 text-muted">${pi.mismatch_notes.join(' · ')}</div>`;
+ }
+ if (pi && pi.refine_note) {
+ html += `<div class="small mt-1 text-info">${pi.refine_note}</div>`;
+ }
+ el.innerHTML = html;
+}
+
+function optunaFormatJobPeriodCell(job) {
+ const pi = job.period_info;
+ const base = optunaFmtPeriodShort(job.start, job.end);
+ if (!pi || !pi.has_refine) return base;
+ if (!pi.mismatch) return base;
+ const p1 = pi.phase1 && pi.phase1.range ? pi.phase1.range : '';
+ const p2 = (pi.phase2 && pi.phase2.study && pi.phase2.range) ? pi.phase2.range : '';
+ let inner = '<span class="opt-period-cell-warn" title="기간 불일치">⚠</span> ' + base;
+ if (p1 && p1 !== base) inner += `<br><span class="small text-warning" title="1차 TPE">1차 ${p1}</span>`;
+ if (p2 && p2 !== base && p2 !== p1) inner += `<br><span class="small text-warning" title="2차 TPE">2차 ${p2}</span>`;
+ return inner;
+}
+
function optunaRenderJob(job) {
if (!job) return;
if (job.job_id) {
@@ -9460,6 +9588,25 @@ function optunaRenderJob(job) {
if ($('opt_st_study')) {
$('opt_st_study').textContent = job.active_study_name || job.study_name || '—';
}
+ const refineWrap = $('opt_st_refine_wrap');
+ const refineEl = $('opt_st_refine');
+ const piRef = job.period_info;
+ if (refineWrap && refineEl) {
+ if (piRef && piRef.has_refine && piRef.phase1 && piRef.phase2) {
+ const p1r = piRef.phase1.range || optunaFmtPeriodShort(piRef.phase1.start, piRef.phase1.end);
+ const p2r = piRef.phase2.range || optunaFmtPeriodShort(piRef.phase2.start, piRef.phase2.end);
+ const p1s = (piRef.phase1.study || '').split('_').slice(-3, -1).join('_') || '1차';
+ refineEl.innerHTML =
+ `<span class="text-muted">1차</span> <code title="${piRef.phase1.study || ''}">${(piRef.phase1.study || '—').slice(0, 48)}</code>` +
+ ` <b>${p1r}</b>` +
+ ` · <span class="text-muted">2차</span> <code title="${piRef.phase2.study || ''}">${(piRef.phase2.study || '—').slice(0, 48)}</code>` +
+ ` <b>${p2r}</b>`;
+ refineWrap.classList.remove('d-none');
+ } else {
+ refineEl.textContent = '—';
+ refineWrap.classList.add('d-none');
+ }
+ }
if ($('opt_st_trials')) {
const met = optunaBestMetricsText(prog, job);
const nowBits = [
@@ -9500,7 +9647,7 @@ function optunaRenderJob(job) {
if ($('opt_st_post')) {
const busy = optunaPostBusy(job);
$('opt_st_post').textContent = post.hint
- || (busy ? '후처리 중 · 「상세」는 끝난 뒤' : (job.status === 'done' ? '후처리 — 표가 있으면 「상세」' : '후처리 — trial 끝난 뒤 시작'));
+ || (busy ? '후처리 중' : (job.status === 'done' ? '후처리 —' : '후처리 — trial 끝난 뒤 시작'));
}
if ($('opt_btn_mode_detail')) $('opt_btn_mode_detail').disabled = optunaPostBusy(job);
if ($('opt_btn_pp_rerun')) $('opt_btn_pp_rerun').disabled = optunaPostBusy(job);
@@ -9530,11 +9677,32 @@ function optunaRenderJob(job) {
const lab = k.label || k.strategy || jid;
return `<button type="button" class="btn btn-sm btn-outline-primary py-0 me-1 mb-1" onclick="optunaWatch('${jid}')">${lab} · gated ${k.n_gated ?? '—'} / ${k.n_all ?? '—'}</button>`;
}).join('');
+ } else if (job.status === 'running' && job.live_summary && (job.live_summary.top3_learn || []).length) {
+ const ls = job.live_summary;
+ const phase = ls.refine_phase === 'phase2' ? '2차' : '1차';
+ const pr = ls.period_range || optunaFmtPeriodShort(ls.period_start, ls.period_end);
+ let html = `<div class="mb-1 text-info">${phase} learn Top3 · 기간 <b>${pr}</b> (진행 중 · 축 분포 확인용)</div>`;
+ (ls.top3_learn || []).forEach((t, i) => {
+ html += `<div class="small">${optunaFormatTopRow(t, i + 1)}</div>`;
+ });
+ if (ls.refine_phase === 'phase2' && (ls.phase1_top3 || []).length) {
+ const p1pr = ls.phase1_period_range || '';
+ html += `<div class="mt-2 mb-1 text-muted small">1차 Top3 (참고${p1pr ? ' · 기간 ' + p1pr : ''})</div>`;
+ ls.phase1_top3.forEach((t, i) => {
+ html += `<div class="small text-muted">${optunaFormatTopRow(t, i + 1)}</div>`;
+ });
+ }
+ $('opt_st_top').innerHTML = html;
+ window._optunaLastSummary = { top5_learn: ls.top3_learn || [], n_all: ls.n_complete };
+ optunaRenderTop5(window._optunaLastSummary);
} else if (sum && sum.top) {
const t = sum.top;
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 mtBit = (sum.min_trades != null)
? ` · min≥${sum.min_trades}` +
(sum.n_trading_days != null
@@ -9551,6 +9719,7 @@ function optunaRenderJob(job) {
`거래 ${t.total_trades} · WR ${Number(t.win_rate || 0).toFixed(1)}% · ` +
`PF ${Number(t.pf || 0).toFixed(2)} · PnL ${Number(t.total_pnl || 0).toLocaleString()}원` +
(dAvg != null ? ` · 일평균 ${Number(dAvg).toLocaleString()}원` : '') +
+ (dPct != null ? ` · 일평균 ${Number(dPct) >= 0 ? '+' : ''}${Number(dPct).toFixed(3)}%` : '') +
` · gated ${sum.n_gated}/${sum.n_all}` +
(sum.n_stable != null ? ` · stable ${sum.n_stable}` : '') +
mtBit;
@@ -9583,11 +9752,175 @@ function optunaRenderJob(job) {
}
optunaRenderCompare(sum);
optunaRenderTop5(sum);
+ optunaRenderGatedTop3Card(sum, job);
if (!window._optunaPostSel) window._optunaPostSel = { source: 'gated', rank: 1 };
optunaRenderPostprocess(sum);
+ optunaRenderPeriodBanner(job);
optunaSetNav(job);
}
+async function optunaBacktestFromCandidate(source, rank) {
+ if (!_optunaJobId) {
+ alert('선택된 job 없음 — 최근 잡에서「보기」로 job을 먼저 고르세요');
+ return;
+ }
+ const src = source || 'gated';
+ const rk = rank || 1;
+ if (src === 'mode') {
+ alert('mode_combo는 trial 번호가 없어 백테탭 자동 채우기를 지원하지 않습니다.');
+ return;
+ }
+ const curJob = window._optunaLastJob;
+ if (curJob && (curJob.kind === 'seq' || curJob.kind === 'seq4') && (curJob.child_jobs || []).length) {
+ alert('순차 묶음 잡입니다. 전략별 import 잡에서「백테」를 누르세요.');
+ return;
+ }
+ try {
+ const qs = new URLSearchParams({
+ job_id: _optunaJobId,
+ source: src,
+ rank: String(rk),
+ });
+ const r = await fetch('/api/optuna/candidate?' + qs.toString());
+ const j = await r.json();
+ if (!j.ok) {
+ alert('❌ ' + (j.error || '후보 없음'));
+ return;
+ }
+ window._optunaCandidateCache = {
+ strategy: j.strategy,
+ start: j.start,
+ end: j.end,
+ params: j.params_full || j.params_preview || {},
+ metrics: j.metrics || {},
+ };
+ const cache = window._optunaCandidateCache;
+ const strat = String(cache.strategy || curJob?.strategy || '').toLowerCase();
+ const p = cache.params || {};
+ const job = curJob || {};
+ const trialNo = (j.metrics && j.metrics.optuna_trial_number != null)
+ ? j.metrics.optuna_trial_number
+ : '—';
+
+ const applyJobSources = (cfg) => {
+ if (cache.start && cfg.startId) setDateVal(cfg.startId, cache.start);
+ if (cache.end && cfg.endId) setDateVal(cfg.endId, cache.end);
+ const hist = job.universe_history_source
+ || $('opt_univ_history_source')?.value
+ || 'kiwoom';
+ if (cfg.univSrcId && $(cfg.univSrcId)) $(cfg.univSrcId).value = hist;
+ if (cfg.univId && $(cfg.univId)) $(cfg.univId).checked = true;
+ if (job.candle_source && cfg.candleId && $(cfg.candleId)) {
+ $(cfg.candleId).value = job.candle_source;
+ }
+ if (job.tick_source && cfg.tickId && $(cfg.tickId)) {
+ $(cfg.tickId).value = job.tick_source;
+ }
+ if (job.ob_source && cfg.obId && $(cfg.obId)) {
+ $(cfg.obId).value = job.ob_source;
+ }
+ };
+
+ const tabMap = {
+ breakout: 'breakout',
+ tail: 'tail',
+ scalp: 'backtest',
+ momentum: 'momentum',
+ us_momentum: 'us_momentum',
+ };
+ const tabLabel = {
+ breakout: '돌파매매',
+ tail: '꼬리잡기',
+ scalp: '스캘핑',
+ momentum: '모멘텀',
+ us_momentum: '해외 모멘텀',
+ };
+
+ if (strat === 'momentum' || strat === 'us_momentum') {
+ await optunaFillFormFromCandidate(src, rk);
+ return;
+ }
+
+ if (strat === 'breakout') {
+ applyJobSources({
+ startId: 'bo_start',
+ endId: 'bo_end',
+ univId: 'bo_use_univ_history',
+ univSrcId: 'bo_univ_history_source',
+ candleId: 'bo_candle_source',
+ tickId: 'bo_tick_source',
+ obId: 'bo_ob_source',
+ });
+ fillBreakoutFormFromOptunaParams(p);
+ } else if (strat === 'tail') {
+ applyJobSources({
+ startId: 'tl_start',
+ endId: 'tl_end',
+ univId: 'tl_use_univ_history',
+ univSrcId: 'tl_univ_history_source',
+ tickId: 'tl_tick_source',
+ obId: 'tl_ob_source',
+ });
+ fillTailFormFromOptunaParams(p);
+ } else if (strat === 'scalp') {
+ applyJobSources({
+ startId: 'bt_start',
+ endId: 'bt_end',
+ univId: 'bt_use_univ_history',
+ univSrcId: 'bt_univ_history_source',
+ tickId: 'bt_tick_source',
+ obId: 'bt_ob_source',
+ });
+ fillScalpFormFromOptunaParams(p);
+ } else {
+ alert('백테탭 자동 채우기 미지원 전략: ' + strat);
+ return;
+ }
+
+ const wantTab = tabMap[strat] || strat;
+ const tab = document.querySelector(`[data-tab="${wantTab}"]`);
+ if (tab && !tab.classList.contains('active')) tab.click();
+
+ const panelId = wantTab === 'backtest' ? 'tab-backtest' : ('tab-' + wantTab);
+ const panel = $(panelId);
+ if (panel) panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
+
+ alert(
+ `${tabLabel[strat] || strat} 백테 탭에 반영했습니다.\n` +
+ `trial #${trialNo} · ${src} #${rk}\n` +
+ `기간 ${cache.start || ''}~${cache.end || ''}\n` +
+ '「백테 실행」으로 Optuna 숫자와 비교하세요.\n(DB 저장 아님)'
+ );
+ } catch (e) {
+ alert('백테 폼 반영 오류: ' + e);
+ }
+}
+
+function fillBreakoutFormFromOptunaParams(p) {
+ if (!p || typeof p !== 'object') return;
+ const b = Object.assign({}, p);
+ if (b.ob_filter_enabled == null && b._orderbook_filter_enabled != null) {
+ b.ob_filter_enabled = !!b._orderbook_filter_enabled;
+ }
+ if (b.pg_filter_enabled == null && b._program_filter_enabled != null) {
+ b.pg_filter_enabled = !!b._program_filter_enabled;
+ }
+ fillBreakoutFormFromApi(b);
+ if (typeof fillObWhipReadonly === 'function') fillObWhipReadonly('bo', b);
+}
+
+function fillTailFormFromOptunaParams(p) {
+ if (!p || typeof p !== 'object') return;
+ fillTailFormFromApi(p);
+ if (typeof fillObWhipReadonly === 'function') fillObWhipReadonly('tl', p);
+}
+
+function fillScalpFormFromOptunaParams(p) {
+ if (!p || typeof p !== 'object') return;
+ fillScalpFormFromApi(p);
+ if (typeof fillObWhipReadonly === 'function') fillObWhipReadonly('bt', p);
+}
+
async function optunaFillFormFromCandidate(source, rank) {
// 캐시 없으면 다시 fetch
let cache = window._optunaCandidateCache;
@@ -9763,7 +10096,7 @@ async function optunaApplyUpto(source, rank, upto) {
}
}
} catch (e) { /* ignore */ }
- const who = (src === 'mode' ? 'mode_combo(축최빈조립·trial없음)' : (src === 'stable' ? `안정 #${rk}` : (src === 'learn' ? `학습 #${rk}` : `사후합격 #${rk}`)));
+ const who = (src === 'mode' ? 'mode_combo(축최빈조립·trial없음)' : (src === 'consensus' ? `mode Top10 #${rk}` : (src === 'stable' ? `안정 #${rk}` : (src === 'learn' ? `학습 #${rk}` : `사후합격 #${rk}`))));
// 적용 직전: 익절/손절 숫자를 confirm에 보여 혼동(mode vs gated) 방지
try {
const sum2 = window._optunaLastSummary || null;
@@ -9772,7 +10105,7 @@ async function optunaApplyUpto(source, rank, upto) {
const mr = ((sum2 && sum2.compare_rows) || []).find((r) => r.source === 'mode');
pr = (mr && (mr.params || mr)) || (sum2 && sum2.mode_combo_summary && sum2.mode_combo_summary.params) || null;
} else {
- const ofKey2 = src === 'stable' ? 'top5_stable' : (src === 'learn' ? 'top5_learn' : 'top5_gated');
+ const ofKey2 = src === 'stable' ? 'top5_stable' : (src === 'consensus' ? 'top5_consensus' : (src === 'learn' ? 'top5_learn' : 'top5_gated'));
const row2 = ((sum2 && sum2[ofKey2]) || []).find((x) => Number(x.rank) === Number(rk));
pr = row2 && (row2.params || row2);
}
@@ -10023,9 +10356,17 @@ async function optunaStart() {
const entryModes = optunaTailEntryModes();
const slModes = optunaBreakoutSlModes();
const obModes = strategies.includes('breakout') ? optunaBreakoutObModes() : [];
+ const boCombos = strategies.includes('breakout')
+ ? slModes.flatMap(sm => obModes.map(om => `${sm}×${om}`))
+ : [];
if (!strategies.length) { alert('전략을 1개 이상 체크하세요'); return; }
if (!start || !end) { alert('시작·종료일을 입력하세요'); return; }
- const how = strategies.length >= 2 ? `순차 ${strategies.length}개` : strategies[0];
+ const needSeq = strategies.length >= 2
+ || (strategies.includes('tail') && entryModes.length >= 2)
+ || (strategies.includes('breakout') && boCombos.length >= 2);
+ const how = needSeq
+ ? `순차1·2차 (${[...strategies, ...(entryModes.length >= 2 ? ['꼬리×2'] : []), ...(boCombos.length >= 2 ? [`돌파×${boCombos.length}`] : [])].join('+')})`
+ : strategies[0];
const srcLabel = univSrc === 'ls' ? 'LS' : '키움';
const sortLabel = ({
score: '수익·낙폭·표본',
@@ -10034,17 +10375,17 @@ async function optunaStart() {
pnl: '총손익',
})[sortBy] || sortBy;
const emLabel = strategies.includes('tail')
- ? `\n꼬리진입=${entryModes.join('+')}` + (entryModes.length >= 2 ? ' (순차 2스터디)' : '')
+ ? `\n꼬리진입=${entryModes.join('+')}` + (entryModes.length >= 2 ? ' (순차·각 1·2차)' : '')
: '';
- const boCombos = strategies.includes('breakout')
- ? slModes.flatMap(sm => obModes.map(om => `${sm}×${om}`))
- : [];
const slLabel = strategies.includes('breakout')
- ? `\n돌파=${boCombos.join('+')}` + (boCombos.length >= 2 ? ` (순차 ${boCombos.length}스터디)` : '')
+ ? `\n돌파=${boCombos.join('+')}` + (boCombos.length >= 2 ? ` (순차·각 1·2차)` : '')
: '';
+ const chainLabel = needSeq
+ ? `\n각 전략/조합: 1차(넓은 Grid) → 2차(밴드 축소) 후 다음`
+ : `\n1·2차 TPE 연쇄 (1차 넓은 Grid → 2차 밴드 축소)`;
if (!confirm(`Optuna 시작?\n${how}: ${strategies.join(', ')}\n${start}~${end} trials=${trials}` +
(studyTrials ? `\n스터디 총 횟수=${studyTrials}` : '') +
- `\n승리식=${sortLabel}\n이력소스=${srcLabel}${emLabel}${slLabel}\n(DB 미적용)`)) return;
+ `${chainLabel}\n승리식=${sortLabel}\n이력소스=${srcLabel}${emLabel}${slLabel}\n(DB 미적용)`)) return;
try {
const r = await fetch('/api/optuna/start', {
method: 'POST',
@@ -10124,6 +10465,48 @@ async function optunaStop() {
} catch (e) { alert('오류: ' + e); }
}
+function optunaFormatModeTopRow(t, i) {
+ 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 prox = t.consensus_match_pct;
+ const proxN = t.consensus_match_n;
+ const proxOf = t.consensus_match_of;
+ let proxBit = '';
+ if (prox != null) {
+ proxBit = ` · 근접 ${Number(prox).toFixed(1)}%`;
+ if (proxN != null && proxOf != null) proxBit += ` (${proxN}/${proxOf}축)`;
+ }
+ return `#${i} trial ${t.optuna_trial_number ?? '—'} · PnL ${Number(t.total_pnl || 0).toLocaleString()}원` +
+ (dAvg != null ? ` · 일평균 ${Number(dAvg).toLocaleString()}원` : '') +
+ (dPct != null ? ` · 일평균 ${Number(dPct) >= 0 ? '+' : ''}${Number(dPct).toFixed(3)}%` : '') +
+ proxBit +
+ ` · WR ${Number(t.win_rate || 0).toFixed(1)}% · PF ${Number(t.pf || 0).toFixed(2)}`;
+}
+
+function optunaBuildModeTop3Html(rows, opts) {
+ const o = opts || {};
+ const n = o.topN || OPTUNA_CARD_TOP_N;
+ const list = (rows || []).slice(0, n);
+ if (!list.length) return '';
+ const pr = o.periodRange ? ` · 기간 <b>${o.periodRange}</b>` : '';
+ const pool = o.poolSize != null ? ` · pool ${o.poolSize}건` : '';
+ const head = o.title || `mode Top${n} (밴드 근접)`;
+ let html = `<div class="mt-1 mb-1 text-warning">${head}${pr}${pool}</div>`;
+ list.forEach((t, i) => {
+ html += `<div class="small">${optunaFormatModeTopRow(t, i + 1)}</div>`;
+ });
+ return html;
+}
+
+function optunaFormatTopRow(t, i) {
+ 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;
+ return `#${i} trial ${t.optuna_trial_number ?? '—'} · PnL ${Number(t.total_pnl || 0).toLocaleString()}원` +
+ (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)} · score ${Number(t.score || 0).toFixed(3)}`;
+}
+
async function optunaRefreshJobs() {
try {
const sort = $('opt_jobs_sort')?.value || 'started';
@@ -10139,20 +10522,30 @@ async function optunaRefreshJobs() {
const prog = job.progress || {};
const jid = job.job_id;
const label = job.label || job.strategy || '';
+ const studyNote = (job.study_short && job.kind === 'import')
+ ? `<br><span class="text-muted" style="font-size:10px" title="${job.study_name || ''}">${job.study_short}</span>`
+ : (job.source === 'cli' && job.study_short
+ ? `<br><span class="text-muted" style="font-size:10px">${job.study_short}</span>`
+ : '');
const sel = jid === _optunaJobId ? 'outline:1px solid var(--accent)' : '';
const st = String(job.status || '');
+ const refinePh = prog.refine_phase || '';
+ const phaseTag = refinePh === 'phase2' ? '2차' : (refinePh === 'phase1' ? '1차' : '');
const stCell = (st === 'running')
- ? `<button type="button" class="btn btn-sm btn-outline-warning py-0" title="다른 PC 명령 보기" onclick="optunaOpenJoinCmd('${jid}')">running</button>`
+ ? `<button type="button" class="btn btn-sm btn-outline-warning py-0" title="다른 PC 명령 보기" onclick="optunaOpenJoinCmd('${jid}')">${phaseTag ? phaseTag + ' ' : ''}running</button>`
: st;
+ const statusNote = (st === 'running')
+ ? (job.leftover_note ? ` ·${job.leftover_note}` : (phaseTag ? ` ·${phaseTag} TPE` : ''))
+ : (job.leftover_note ? ` ·${job.leftover_note}` : '');
const extraBtns = (st === 'done' && job.can_continue)
? `<button class="btn btn-sm btn-outline-primary py-0 ms-1" onclick="optunaContinue('${jid}')">이어 돌리기</button>` +
`<button class="btn btn-sm btn-outline-warning py-0 ms-1" onclick="optunaConfirmStudy('${jid}')">확정</button>`
: '';
return `<tr data-job-id="${jid}" style="${sel}">
<td><code style="font-size:10px">${jid}</code></td>
- <td>${label}</td>
- <td>${job.start || ''}~${job.end || ''}</td>
- <td>${stCell}${job.phase === 'postprocess' ? ' ·후처리' : ''}${job.leftover_note ? ' ·' + job.leftover_note : ''}</td>
+ <td>${label}${studyNote}</td>
+ <td>${optunaFormatJobPeriodCell(job)}</td>
+ <td>${stCell}${job.phase === 'postprocess' ? ' ·후처리' : ''}${statusNote}</td>
<td>${(() => {
const done = prog.trials_done ?? '—';
const batch = prog.trials_total ?? job.trials ?? '—';
@@ -10183,13 +10576,68 @@ function optunaCloseJoinCmd() {
function optunaFillJoinOverlay(job) {
if (!job) return;
- if ($('opt_join_hint')) $('opt_join_hint').textContent = job.join_hint || '';
+ const pi = job.period_info;
+ let periodHint = '';
+ if (pi && (pi.has_refine || pi.active && pi.active.study)) {
+ const bits = [];
+ if (job.start && job.end) bits.push(`잡 ${optunaFmtPeriodShort(job.start, job.end)}`);
+ if (pi.phase1 && pi.phase1.start) bits.push(`1차 ${pi.phase1.range || optunaFmtPeriodShort(pi.phase1.start, pi.phase1.end)}`);
+ if (pi.phase2 && pi.phase2.study && pi.phase2.start) bits.push(`2차 ${pi.phase2.range || optunaFmtPeriodShort(pi.phase2.start, pi.phase2.end)}`);
+ if (bits.length) {
+ periodHint = (pi.mismatch ? '⚠ 기간 불일치 · ' : '📅 ') + bits.join(' · ');
+ }
+ }
+ if ($('opt_join_hint')) {
+ const base = job.join_hint || '';
+ $('opt_join_hint').textContent = periodHint ? (periodHint + (base ? '\n' + base : '')) : base;
+ }
if ($('opt_join_study')) {
$('opt_join_study').textContent = job.join_study || job.active_study_name || job.study_name || '—';
}
if ($('opt_join_cmd')) $('opt_join_cmd').textContent = job.join_cmd || '(아직 study 없음 · 첫 START 후)';
if ($('opt_join_cmd_ps')) $('opt_join_cmd_ps').textContent = job.join_cmd_ps || '(아직 study 없음 · 첫 START 후)';
+ if ($('opt_web_cmd_full')) {
+ $('opt_web_cmd_full').textContent = job.web_cmd_full || job.web_cmd || job.cmd || '—';
+ }
if ($('opt_web_cmd')) $('opt_web_cmd').textContent = job.web_cmd || job.cmd || '—';
+ const refineBox = $('opt_join_refine');
+ if (refineBox) {
+ const rows = job.seq_refine_cmds || [];
+ window._optunaSeqRefine = rows;
+ if (!rows.length) {
+ refineBox.innerHTML = '';
+ } else {
+ refineBox.innerHTML =
+ '<div class="fw-semibold mb-1">PC별 병렬 — 전략/조합마다 1·2차 (refine runner)</div>' +
+ '<div class="small text-muted mb-2">MariaDB 공유 · PC마다 한 줄 · 2차만은 --phase1-study (DB payload)</div>' +
+ rows.map((row, i) => {
+ const done = row.phase1_db
+ ? ' <span class="text-success">(1차 DB OK)</span>'
+ : (row.done ? ' <span class="text-success">(1차 OK)</span>' : '');
+ const note = row.phase2_note ? `<div class="text-muted" style="font-size:10px">${row.phase2_note}</div>` : '';
+ return `<div class="border rounded p-2 mb-2" style="border-color:var(--border)!important">
+ <div class="d-flex justify-content-between align-items-center flex-wrap gap-1">
+ <span><strong>${row.step}.</strong> ${row.label || row.strategy}${done}</span>
+ <span>
+ <button type="button" class="btn btn-sm btn-success py-0" onclick="optunaCopySeqRefine(${i},'full')">1·2차 bash</button>
+ <button type="button" class="btn btn-sm btn-outline-success py-0 ms-1" onclick="optunaCopySeqRefine(${i},'full_ps')">1·2차 PS</button>
+ <button type="button" class="btn btn-sm btn-warning py-0 ms-1" onclick="optunaCopySeqRefine(${i},'p2')">2차만 bash</button>
+ <button type="button" class="btn btn-sm btn-outline-warning py-0 ms-1" onclick="optunaCopySeqRefine(${i},'p2_ps')">2차만 PS</button>
+ </span>
+ </div>
+ ${note}
+ <pre class="opt-join-pre mb-1 mt-1" id="opt_refine_full_${i}" style="max-height:120px"></pre>
+ <pre class="opt-join-pre mb-0" id="opt_refine_p2_${i}" style="max-height:100px"></pre>
+ </div>`;
+ }).join('');
+ rows.forEach((row, i) => {
+ const pf = document.getElementById('opt_refine_full_' + i);
+ if (pf) pf.textContent = row.cmd || '';
+ const p2 = document.getElementById('opt_refine_p2_' + i);
+ if (p2) p2.textContent = row.cmd_phase2 || '';
+ });
+ }
+ }
const box = $('opt_join_all');
if (box) {
const all = job.join_cmds_all || [];
@@ -10198,11 +10646,11 @@ function optunaFillJoinOverlay(job) {
return;
}
window._optunaJoinAll = all;
- box.innerHTML = '<div class="text-muted mb-1">순차 스터디별 (이미 START 된 것만)</div>' +
+ box.innerHTML = '<div class="text-muted mb-1 mt-2">스터디별 trial 추가 (1·2차 study 이름)</div>' +
all.map((row, i) => {
const lab = [row.strategy, row.extra].filter(Boolean).join('/');
return `<div class="d-flex justify-content-between align-items-center mt-2">
- <span class="text-muted">${i + 1}. ${lab}</span>
+ <span class="text-muted">${i + 1}. ${lab} · ${row.study || ''}</span>
<span>
<button type="button" class="btn btn-sm btn-outline-secondary py-0" onclick="optunaCopyJoinAll(${i},'sh')">bash</button>
<button type="button" class="btn btn-sm btn-outline-primary py-0 ms-1" onclick="optunaCopyJoinAll(${i},'ps')">PS</button>
@@ -10220,6 +10668,16 @@ function optunaFillJoinOverlay(job) {
}
}
+function optunaCopySeqRefine(i, which) {
+ const row = (window._optunaSeqRefine || [])[i];
+ if (!row) return;
+ let t = row.cmd || '';
+ if (which === 'full_ps') t = row.cmd_ps || '';
+ if (which === 'p2') t = row.cmd_phase2 || '';
+ if (which === 'p2_ps') t = row.cmd_phase2_ps || '';
+ optunaCopyText(t);
+}
+
function optunaCopyText(text) {
const t = String(text || '');
if (!t) return;
@@ -10239,6 +10697,7 @@ function optunaCopyJoinAll(i, which) {
function optunaCopyJoin(which) {
let el = $('opt_join_cmd');
if (which === 'web') el = $('opt_web_cmd');
+ if (which === 'web_full') el = $('opt_web_cmd_full');
if (which === 'ps') el = $('opt_join_cmd_ps');
optunaCopyText(el ? el.textContent : '');
}
diff --git a/templates/backtest.html b/templates/backtest.html
index 501926a..2b3ef77 100644
--- a/templates/backtest.html
+++ b/templates/backtest.html
@@ -4166,11 +4166,12 @@
<button class="btn btn-sm btn-outline-secondary" onclick="optunaRefreshJobs()">새로고침</button>
</div>
</div>
- <small class="text-muted d-block mt-2">승리식 기본=수익·낙폭·표본 · (구)=PnL/max(MDD,하한) · min_trades: 꼬리=1 고정 · 그 외=거래일×2(기본) · 탐색 WR/PF=0 · 사후=results_gated · DB 자동적용 안 함 · 2개 이상=순차 · 이력소스=후보 테이블 · 꼬리: align/limit_atr · 돌파: fixed/atr×호가 off/on</small>
+ <small class="text-muted d-block mt-2">전략 1개만=1·2차 연쇄 · 2개 이상·꼬리2모드·돌파多=순차(조합마다 1·2차) · 승리식=수익·낙폭·표본 · min_trades: 꼬리=1 · 그 외=거래일×2 · WR/PF=0 · DB 미적용</small>
</div>
<div class="card p-3 mb-3" id="opt_status_card">
<h6>진행</h6>
+ <div id="opt_period_banner" class="opt-period-banner d-none mb-2 p-2 rounded small"></div>
<div class="small text-muted mb-1">학습 trial</div>
<div class="optuna-prog-track mb-2" style="height:10px">
<div class="optuna-prog-fill" id="opt_tab_fill" style="width:0%"></div>
@@ -4187,6 +4188,7 @@
</div>
<div><span class="text-muted">잡</span> <code id="opt_st_job">—</code></div>
<div><span class="text-muted">study</span> <code id="opt_st_study" style="font-size:11px">—</code></div>
+ <div id="opt_st_refine_wrap" class="d-none"><span class="text-muted">1·2차</span> <span id="opt_st_refine" class="small" style="font-size:11px">—</span></div>
<div><span class="text-muted">trial</span> <span id="opt_st_trials">—</span></div>
</div>
<div class="col-md-6">
@@ -4213,6 +4215,28 @@
</div>
</div>
+ <div class="card p-3 mb-3" id="opt_gated_top3_card">
+ <h6 id="opt_gated_top3_title">사후합격 Top5 <small class="text-muted">DB 적용 후보 · 일평균(원/%) · 익절/손절 비교</small></h6>
+ <div id="opt_gated_top3_body" class="small text-muted">완료 후 표시</div>
+ </div>
+
+ <div class="card p-3 mb-3" id="opt_param_dist_card">
+ <h6>사후합격 pool 파라미터 분포 <small class="text-muted">분포 참고용 · DB 적용 1순위 아님</small></h6>
+ <div id="opt_param_dist_legend" class="small mb-2 p-2 rounded" style="background:rgba(56,139,253,0.06);border:1px solid var(--border);font-size:11px;line-height:1.6">
+ <div class="fw-semibold mb-1">범례 (막대 1줄 = 파라미터 1개)</div>
+ <div class="d-flex flex-wrap gap-3 align-items-center">
+ <span><span style="display:inline-block;width:28px;height:10px;background:rgba(56,139,253,0.45);border-radius:2px;vertical-align:middle"></span>
+ <b class="ms-1">파란 띠</b> = p25~p75 · 사후합격 후보들의 <b>중간 50% 구간</b> (너무 극단값 제외한 흔한 범위)</span>
+ <span><span style="display:inline-block;width:2px;height:12px;background:#3fb950;vertical-align:middle"></span>
+ <b class="ms-1">녹색 세로줄</b> = median · <b>중앙값</b> (위·아래 절반이 이 값 기준)</span>
+ <span><span style="display:inline-block;width:8px;height:8px;background:#f0883e;border-radius:50%;vertical-align:middle"></span>
+ <b class="ms-1">주황 동그라미</b> = mode · <b>최빈값</b> (후보 중 가장 많이 나온 숫자 · 괄호 %는 비율)</span>
+ </div>
+ <div class="text-muted mt-1">아래 숫자(p25·med·p75)는 막대와 동일. 익절·손절이 Top5마다 다르게 보이면 이 밴드가 넓다는 뜻입니다.</div>
+ </div>
+ <div id="opt_param_dist_chart" class="small text-muted">완료 후 표시</div>
+ </div>
+
<div class="card p-3 mb-3">
<div id="opt_overfit_box" class="p-2 small" style="background:rgba(248,81,73,0.06);border:1px solid var(--border);border-radius:6px">
<div class="d-flex flex-wrap justify-content-between gap-2 mb-1">
@@ -4272,7 +4296,8 @@
<tr>
<th>#</th><th>trial</th><th class="text-end">거래</th>
<th class="text-end">승률</th><th class="text-end">PF</th><th class="text-end">PnL</th>
- <th class="text-end" title="총손익 ÷ 기간 한국거래일수. 짧은/긴 구간 비교용.">일평균</th>
+ <th class="text-end" title="총손익 ÷ 기간 한국거래일수. 짧은/긴 구간 비교용.">일평균(원)</th>
+ <th class="text-end" title="총수익률(운용한도) ÷ 기간 거래일수">일평균%</th>
<th title="본 TPE: 호가ON/OFF·spr/r/ask · 익절%/상한 · 손절% · 휩쏘ON/OFF·수치 (사후8방 아님)">호가·익절·손절·휩쏘</th>
<th class="text-end">손실일</th><th class="text-end">최악일</th>
<th class="text-end" title="높을수록 좋음. 만점 없음. 일평균PnL λ×일표준편차(원).">안정점수 ↑</th>
@@ -4281,7 +4306,7 @@
</tr>
</thead>
<tbody id="opt_top5_learn_tbody">
- <tr><td colspan="13" class="text-muted">완료 후 표시</td></tr>
+ <tr><td colspan="14" class="text-muted">완료 후 표시</td></tr>
</tbody>
</table>
</div>
@@ -4295,7 +4320,8 @@
<tr>
<th>#</th><th>trial</th><th class="text-end">거래</th>
<th class="text-end">승률</th><th class="text-end">PF</th><th class="text-end">PnL</th>
- <th class="text-end" title="총손익 ÷ 기간 한국거래일수. 짧은/긴 구간 비교용.">일평균</th>
+ <th class="text-end" title="총손익 ÷ 기간 한국거래일수. 짧은/긴 구간 비교용.">일평균(원)</th>
+ <th class="text-end" title="총수익률(운용한도) ÷ 기간 거래일수">일평균%</th>
<th title="본 TPE: 호가ON/OFF·spr/r/ask · 익절%/상한 · 손절% · 휩쏘ON/OFF·수치 (사후8방 아님)">호가·익절·손절·휩쏘</th>
<th class="text-end">손실일</th><th class="text-end">최악일</th>
<th class="text-end" title="높을수록 좋음. 만점 없음. 일평균PnL λ×일표준편차(원).">안정점수 ↑</th>
@@ -4304,14 +4330,14 @@
</tr>
</thead>
<tbody id="opt_top5_tbody">
- <tr><td colspan="13" class="text-muted">완료 후 표시</td></tr>
+ <tr><td colspan="14" class="text-muted">완료 후 표시</td></tr>
</tbody>
</table>
</div>
</div>
<div class="card p-3 mb-3">
- <h6>mode_combo <small class="text-muted">축별 최빈 조립 · trial 번호 없음 · 탐색 TopN 합의 <b>1회 실측</b> — 표 컬럼=사후합격 Top10과 동일</small></h6>
+ <h6>mode_combo <small class="text-muted">축별 최빈 조립 · PnL 양수 pool · trial 번호 없음 · 2차 완료 후 표시</small></h6>
<div id="opt_mode_combo_body" class="small mb-2">완료 후 표시</div>
<div class="table-responsive">
<table class="table table-sm table-hover mb-0" style="font-size:12px">
@@ -4319,7 +4345,8 @@
<tr>
<th>#</th><th>trial</th><th class="text-end">거래</th>
<th class="text-end">승률</th><th class="text-end">PF</th><th class="text-end">PnL</th>
- <th class="text-end" title="총손익 ÷ 기간 한국거래일수. 짧은/긴 구간 비교용.">일평균</th>
+ <th class="text-end" title="총손익 ÷ 기간 한국거래일수. 짧은/긴 구간 비교용.">일평균(원)</th>
+ <th class="text-end" title="총수익률(운용한도) ÷ 기간 거래일수">일평균%</th>
<th title="본 TPE: 호가ON/OFF·spr/r/ask · 익절%/상한 · 손절% · 휩쏘ON/OFF·수치 (사후8방 아님)">호가·익절·손절·휩쏘</th>
<th class="text-end">손실일</th><th class="text-end">최악일</th>
<th class="text-end" title="높을수록 좋음. 만점 없음. 일평균PnL λ×일표준편차(원).">안정점수 ↑</th>
@@ -4328,7 +4355,33 @@
</tr>
</thead>
<tbody id="opt_top5_mode_tbody">
- <tr><td colspan="13" class="text-muted">완료 후 표시 (mode 실측 1행 · 사후합격과 같은 열)</td></tr>
+ <tr><td colspan="14" class="text-muted">완료 후 표시 (mode 실측 1행 · 사후합격과 같은 열)</td></tr>
+ </tbody>
+ </table>
+ </div>
+ </div>
+
+ <div class="card p-3 mb-3">
+ <h6>mode Top10 <small class="text-muted">results_mode · PnL 양수 pool p25~p75 밴드 <b>근접도</b> ↑ · trial 있음 · <b>적용 1순위 아님</b></small></h6>
+ <div id="opt_mode_band_top3" class="small mb-2 text-muted">—</div>
+ <div id="opt_mode_top_meta" class="small text-muted mb-1">—</div>
+ <div class="table-responsive">
+ <table class="table table-sm table-hover mb-0" style="font-size:12px">
+ <thead>
+ <tr>
+ <th>#</th><th>trial</th><th class="text-end" title="Top-N pool 축별 p25~p75 밴드 안/근접 — 100에 가까울수록 흔한 구간">근접% ↑</th><th class="text-end">거래</th>
+ <th class="text-end">승률</th><th class="text-end">PF</th><th class="text-end">PnL</th>
+ <th class="text-end" title="총손익 ÷ 기간 한국거래일수">일평균(원)</th>
+ <th class="text-end" title="총수익률(운용한도) ÷ 기간 거래일수">일평균%</th>
+ <th title="본 TPE trial 호가·익절·손절·휩쏘">호가·익절·손절·휩쏘</th>
+ <th class="text-end">손실일</th><th class="text-end">최악일</th>
+ <th class="text-end" title="일평균PnL λ×일표준편차">안정점수 ↑</th>
+ <th class="text-end" title="과적합 추정 0~100">과적합% ↓</th>
+ <th></th>
+ </tr>
+ </thead>
+ <tbody id="opt_top5_mode_top_tbody">
+ <tr><td colspan="15" class="text-muted">완료 후 표시 (mode Top10 · 구 JSON은 웹에서 즉시 재계산)</td></tr>
</tbody>
</table>
</div>
@@ -4343,7 +4396,8 @@
<tr>
<th>#</th><th>trial</th><th class="text-end">거래</th>
<th class="text-end">승률</th><th class="text-end">PF</th><th class="text-end">PnL</th>
- <th class="text-end" title="총손익 ÷ 기간 한국거래일수. 짧은/긴 구간 비교용.">일평균</th>
+ <th class="text-end" title="총손익 ÷ 기간 한국거래일수. 짧은/긴 구간 비교용.">일평균(원)</th>
+ <th class="text-end" title="총수익률(운용한도) ÷ 기간 거래일수">일평균%</th>
<th title="본 TPE: 호가ON/OFF·spr/r/ask · 익절%/상한 · 손절% · 휩쏘ON/OFF·수치 (사후8방 아님)">호가·익절·손절·휩쏘</th>
<th class="text-end">손실일</th><th class="text-end">최악일</th>
<th class="text-end" title="높을수록 좋음. 만점 없음. 일평균PnL λ×일표준편차(원).">안정점수 ↑</th>
@@ -4352,24 +4406,10 @@
</tr>
</thead>
<tbody id="opt_top5_stable_tbody">
- <tr><td colspan="13" class="text-muted">완료 후 표시 (구 JSON은 재실행 필요)</td></tr>
+ <tr><td colspan="14" class="text-muted">완료 후 표시 (구 JSON은 재실행 필요)</td></tr>
</tbody>
</table>
</div>
- <div id="opt_postprocess_rec" class="mt-3 p-2 small" style="background:rgba(63,185,80,0.06);border:1px solid var(--border);border-radius:6px">
- <div class="text-muted mb-1">후처리 비교표
- <span class="text-muted">(사후합격「상세」· gated 없으면 학습Top learn# 폴백 · mode/live)</span>
- </div>
- <div class="mb-2">
- <button class="btn btn-sm btn-warning py-0" id="opt_btn_pp_rerun" onclick="optunaRerunPostprocess()">이 잡 후처리 재실행</button>
- <button class="btn btn-sm btn-outline-secondary py-0 ms-1" id="opt_btn_mode_detail" onclick="optunaSelectPostprocess('mode',1)">mode 상세</button>
- <span id="opt_pp_rerun_status" class="text-muted ms-1">끝난 잡은 이 버튼이 켜집니다. 8방이 비면 누르세요(호가스냅 부족이면 재실행해도 동일).</span>
- </div>
- <div id="opt_postprocess_consensus" class="mb-1">합의: 완료 후 표시</div>
- <div id="opt_postprocess_cards" style="max-height:min(72vh,680px);overflow:auto;padding-right:4px">
- <div class="text-muted">사후합격 또는 안정 Top에서 「상세」를 누르면 이 표가 바뀝니다.</div>
- </div>
- </div>
</div>
<div class="card p-3 mb-3" id="opt_detail_card">
@@ -4412,24 +4452,30 @@
<div id="opt_join_overlay" class="opt-join-overlay" onclick="if(event.target===this)optunaCloseJoinCmd()">
<div class="opt-join-panel" role="dialog" aria-labelledby="opt_join_title">
<div class="d-flex justify-content-between align-items-center gap-2 mb-2">
- <h6 class="mb-0" id="opt_join_title">다른 PC에서 같은 study 붙이기</h6>
+ <h6 class="mb-0" id="opt_join_title">다른 PC 병렬 · study 이어 붙이기</h6>
<button type="button" class="btn btn-sm btn-outline-secondary py-0" onclick="optunaCloseJoinCmd()">닫기</button>
</div>
<p id="opt_join_hint" class="small text-muted mb-2" style="white-space:pre-wrap"></p>
- <div class="small text-muted">지금 study</div>
+ <div id="opt_join_refine" class="mb-3"></div>
+ <div class="small text-muted">지금 study (trial 추가용)</div>
<code id="opt_join_study" style="font-size:11px">—</code>
<div class="d-flex justify-content-between align-items-center mt-2 mb-1">
- <span class="small text-muted">Linux / VM (bash)</span>
+ <span class="small text-muted">Linux / VM (bash) — 같은 study trial 추가</span>
<button type="button" class="btn btn-sm btn-outline-secondary py-0" onclick="optunaCopyJoin('join')">복사</button>
</div>
<pre id="opt_join_cmd" class="opt-join-pre mb-2">—</pre>
<div class="d-flex justify-content-between align-items-center mb-1">
- <span class="small text-muted">Windows PowerShell (이걸 복사)</span>
+ <span class="small text-muted">Windows PowerShell — trial 추가</span>
<button type="button" class="btn btn-sm btn-primary py-0" onclick="optunaCopyJoin('ps')">복사</button>
</div>
<pre id="opt_join_cmd_ps" class="opt-join-pre mb-2">—</pre>
<div class="d-flex justify-content-between align-items-center mb-1">
- <span class="small text-muted">웹이 실행한 명령 (순차=bash · 그대로 돌리면 새 study)</span>
+ <span class="small text-muted">순차 전체 (env 포함 bash · 이 VM과 동일)</span>
+ <button type="button" class="btn btn-sm btn-outline-secondary py-0" onclick="optunaCopyJoin('web_full')">복사</button>
+ </div>
+ <pre id="opt_web_cmd_full" class="opt-join-pre mb-2">—</pre>
+ <div class="d-flex justify-content-between align-items-center mb-1">
+ <span class="small text-muted">웹 실행 요약 (참고)</span>
<button type="button" class="btn btn-sm btn-outline-secondary py-0" onclick="optunaCopyJoin('web')">복사</button>
</div>
<pre id="opt_web_cmd" class="opt-join-pre mb-2">—</pre>
@@ -4437,6 +4483,6 @@
</div>
</div>
-<script src="{{ url_for('static', filename='js/backtest.js') }}?v=20260823obDbTruth"></script>
+<script src="{{ url_for('static', filename='js/backtest.js') }}?v=20260830opt_p2"></script>
</body>
</html>
\ No newline at end of file