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 =
`
` +
`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}` : '') +
` · 아래 표=사후합격과 같은 열 (안정·과적합 포함). trial 번호는 조립이라 없음.` +
`
` +
(m.note ? `${m.note}
` : '') +
`` +
- `mode 상세(후처리) ` +
- ` mode 타점 DB적용 ` +
+ `mode 타점 DB적용 ` +
`
`;
}
+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 ? ` · ${meta.note} ` : '')
+ + (meta.scoring ? ` · ${meta.scoring} ` : '');
+ } 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 '— ';
+ const n = Number(v);
+ const cls = n >= 0 ? 'text-pnl-pos' : 'text-pnl-neg';
+ return `${optunaFmtNum(v)} `;
+}
+
+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 '— ';
+ const n = Number(p);
+ const cls = n >= 0 ? 'text-pnl-pos' : 'text-pnl-neg';
+ const sign = n > 0 ? '+' : '';
+ return `${sign}${optunaFmtNum(n, 3)}% `;
+}
+
+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)}%${cmN}/${cmOf}축 밴드
`
+ : '—';
+ return `
+ ${rank}
+ #${r.optuna_trial_number ?? '—'}
+ ${cmCell}
+ ${optunaFmtNum(r.total_trades, 0)}
+ ${optunaFmtNum(r.win_rate, 1)}%
+ ${optunaFmtNum(r.pf, 2)}
+ ${optunaFmtNum(r.total_pnl)}
+ ${optunaDailyAvgPnlCell(r)}
+ ${optunaDailyAvgPctCell(r)}
+ ${optunaObWhipCell(r)}
+ ${r.n_losing_days != null ? optunaFmtNum(r.n_losing_days, 0) : '—'}
+ ${r.worst_day_pnl != null ? optunaFmtNum(r.worst_day_pnl) : '—'}
+ ${optunaStableScoreCell(r)}
+ ${optunaOverfitCell(r)}
+
+ 보기
+ 적용
+
+ `;
+}
+
+function optunaFillModeTopTbody(tbodyId, rows, source, emptyMsg) {
+ const tb = $(tbodyId);
+ if (!tb) return;
+ if (!rows || !rows.length) {
+ tb.innerHTML = `${emptyMsg} `;
+ 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 `
+ #${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)} · ${Number(t.total_trades || 0)}건
+ ${optunaObWhipCell(t)}
+ 보기
+ 백테
+ 적용
+
`;
+}
+
+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 ? ` · 기간 ${o.periodRange} ` : '';
+ const phase = o.phaseLabel || '사후합격';
+ const note = o.running
+ ? ' (진행 중 · 사후합격 미확정) '
+ : '';
+ let html = `${phase} Top${n}${pr}${note}
`;
+ 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} DB 적용 후보 · 일평균(원/%) · 익절/손절 비교 `;
+ }
+ 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
+ ? '사후합격 후보 없음 '
+ : '완료 후 표시 ';
+ 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 = `mode 밴드 근접 Top${OPTUNA_CARD_TOP_N} 없음 `;
+ 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
+ ? '분포 데이터 없음 '
+ : '완료 후 표시 ';
+ 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 = '수치형 분포 없음 ';
+ return;
+ }
+ const poolNote = d.threshold_pool
+ ? `표본: ${d.threshold_pool} · n=${d.threshold_pool_n || 0}
`
+ : '';
+ 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 += `
`;
+ }
+ if (mode != null && !Number.isNaN(mode)) {
+ marks += `
`;
+ }
+ const l = pct(p25).toFixed(1);
+ const w = Math.max(1, pct(p75) - pct(p25)).toFixed(1);
+ return `
+
+ ${r.param}
+ mode ${fmt(r.mode)} (${share})
+
+
+
p25 ${fmt(p25)} · med ${fmt(med)} · p75 ${fmt(p75)}
+
`;
+ }).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
- ? '상세 '
- : `상세 `);
+ const detailBtn = '';
const applyCls = src === 'stable' ? 'btn-outline-info' : 'btn-outline-success';
return `
${rank}
@@ -8664,7 +8928,8 @@ function optunaRankTableRow(r, source) {
${optunaFmtNum(r.win_rate, 1)}%
${optunaFmtNum(r.pf, 2)}
${optunaFmtNum(r.total_pnl)}
- ${(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) : '—'}
+ ${optunaDailyAvgPnlCell(r)}
+ ${optunaDailyAvgPctCell(r)}
${optunaObWhipCell(r)}
${r.n_losing_days != null ? optunaFmtNum(r.n_losing_days, 0) : '—'}
${r.worst_day_pnl != null ? optunaFmtNum(r.worst_day_pnl) : '—'}
@@ -8672,6 +8937,7 @@ function optunaRankTableRow(r, source) {
${optunaOverfitCell(r)}
보기
+ ${src !== 'mode' ? `백테 ` : ''}
${detailBtn}
적용
@@ -8682,7 +8948,7 @@ function optunaFillRankTbody(tbodyId, rows, source, emptyMsg) {
const tb = $(tbodyId);
if (!tb) return;
if (!rows || !rows.length) {
- tb.innerHTML = ` ${emptyMsg} `;
+ tb.innerHTML = `${emptyMsg} `;
return;
}
tb.innerHTML = rows.map((r) => optunaRankTableRow(r, source)).join('');
@@ -9062,12 +9328,8 @@ function _optunaBaseCell(a) {
+ (bits.length ? `${bits.join(' · ')}
` : '');
}
-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 = '사후합격 또는 안정 Top5에서 「상세」를 누르면 이 표가 바뀝니다.
';
- 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 =
- '합의(gated+mode, 실매 제외) '
- + `호가 방: ${optunaObComboLabel(cc.combo_id, cc.label)} `
- + ` · ${cc.n || 0}앵커 · PnL ${pnl} · WR ${wr}`
- + ` · 휩쏘: ${cws.ok ? '있음' : '없음'}`
- + ` · 트레일: ${ctr.ok ? ('ARM ' + Number(ctr.arm_krw || 0).toLocaleString()) : '없음'}
`
- + (cons.note ? `${cons.note}
` : '');
- } 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 =
- '합의(gated+mode, 실매 제외) '
- + `${head} · 진입 ${ce.ok ? '있음' : '없음'} · 익절 ${cx.ok ? '있음' : '없음'} · 손절 ${cs.ok ? '있음' : '없음'}`
- + ` · 휩쏘: ${cws.ok ? '있음' : '없음'} · 트레일: ${ctr.ok ? ('ARM ' + Number(ctr.arm_krw || 0).toLocaleString()) : '없음'}
`
- + (note ? `${note}
` : '');
- }
- }
- 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 = 'TopN 후처리 없음(구 JSON). 「이 잡 후처리 재실행」으로 축별 숫자를 채우세요.
'
- + '이 잡 후처리 재실행
';
- 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 =
- ''
- + '사후합격(gated) 0건 → 학습 Top 후처리(learn#) 표시. '
- + 'WR/PF 사후게이트와 무관하게 호가 8방을 돌린 결과입니다. DB적용은 여전히 gated 우선.'
- + '
';
- window._optunaPostSel = { source: 'learn', rank: rk };
- } else if (modeA) {
- a = modeA;
- src = 'mode';
- rk = 1;
- fallbackNote =
- ''
- + '사후합격·학습 앵커 없음 → mode/live 만 표시.'
- + '
';
- 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 =
- '이 순위 후처리 앵커 없음.
'
- + `gated=${gatedN} · stable=${stableN} · `
- + `앵커=${anchors.map((x) => x.id || x.role).join(',') || '없음'}. `
- + '사후합격·학습·안정 Top5「상세」또는 mode 를 선택하세요.
';
- 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 '참고 ';
- if (whipSkip) return '해당없음 ';
- // 구버튼: base+휩쏘 DB적용 (탐색은 후처리 재실행)
- const ws = row.whipsaw || {};
- if (ws && ws.ok === true) {
- return `000 휩쏘적용 `;
- }
- return `000 휩쏘적용 `;
- };
- const baseBtn = () => {
- if (isLive || !rowSrc) return '참고 ';
- return `타점만(호가OFF) `;
- };
- 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 `
-
${title}
-
과적합%=추정. ${row.note || ''} · 호가 방마다 휩쏘 TPE(통과 체결 기준)
-
차트 타점: ${_optunaBaseCell(rowForBase)} · ${baseBtn()}
- ${_optunaObComboTable(ob, canExitStop, rowSrc, rowRk, isLive, row, whipSkip)}
-
-
- 참고 성적 적용
-
-
- 000 방 휩쏘(앵커)
- ${_optunaAxisStatsHtml(row.whipsaw, row.whipsaw)}
- ${whipBtn()}
-
-
-
-
-
`;
- };
- 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 ? `일별 PnL: ${daily}
` : '') +
` 폼에 반영 ` +
+ ` 백테탭 ` +
` 이 후보 DB적용 `;
}
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 ``;
+}
+
+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
+ ? '⚠ 기간이 서로 다릅니다 — Top5·일평균·백테 비교 시 study별 기간을 확인하세요
'
+ : '📅 백테 기간 (전 단계 동일)
';
+ html += '';
+ 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 += '
';
+ if (formMismatch) {
+ html += `상단 폼(${formS}~${formE}) ≠ 선택 잡(${jobS}~${jobE}) — 새 잡 시작 시 폼 기간이 적용됩니다
`;
+ }
+ if (pi && pi.mismatch_notes && pi.mismatch_notes.length) {
+ html += `${pi.mismatch_notes.join(' · ')}
`;
+ }
+ if (pi && pi.refine_note) {
+ html += `${pi.refine_note}
`;
+ }
+ 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 = '⚠ ' + base;
+ if (p1 && p1 !== base) inner += `1차 ${p1} `;
+ if (p2 && p2 !== base && p2 !== p1) inner += `2차 ${p2} `;
+ 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 =
+ `1차 ${(piRef.phase1.study || '—').slice(0, 48)}` +
+ ` ${p1r} ` +
+ ` · 2차 ${(piRef.phase2.study || '—').slice(0, 48)}` +
+ ` ${p2r} `;
+ 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 `${lab} · gated ${k.n_gated ?? '—'} / ${k.n_all ?? '—'} `;
}).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 = `${phase} learn Top3 · 기간 ${pr} (진행 중 · 축 분포 확인용)
`;
+ (ls.top3_learn || []).forEach((t, i) => {
+ html += `${optunaFormatTopRow(t, i + 1)}
`;
+ });
+ if (ls.refine_phase === 'phase2' && (ls.phase1_top3 || []).length) {
+ const p1pr = ls.phase1_period_range || '';
+ html += `1차 Top3 (참고${p1pr ? ' · 기간 ' + p1pr : ''})
`;
+ ls.phase1_top3.forEach((t, i) => {
+ html += `${optunaFormatTopRow(t, i + 1)}
`;
+ });
+ }
+ $('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 ? ` · 기간 ${o.periodRange} ` : '';
+ const pool = o.poolSize != null ? ` · pool ${o.poolSize}건` : '';
+ const head = o.title || `mode Top${n} (밴드 근접)`;
+ let html = `${head}${pr}${pool}
`;
+ list.forEach((t, i) => {
+ html += `${optunaFormatModeTopRow(t, i + 1)}
`;
+ });
+ 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')
+ ? `${job.study_short} `
+ : (job.source === 'cli' && job.study_short
+ ? `${job.study_short} `
+ : '');
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')
- ? `running `
+ ? `${phaseTag ? phaseTag + ' ' : ''}running `
: 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)
? `이어 돌리기 ` +
`확정 `
: '';
return `
${jid}
- ${label}
- ${job.start || ''}~${job.end || ''}
- ${stCell}${job.phase === 'postprocess' ? ' ·후처리' : ''}${job.leftover_note ? ' ·' + job.leftover_note : ''}
+ ${label}${studyNote}
+ ${optunaFormatJobPeriodCell(job)}
+ ${stCell}${job.phase === 'postprocess' ? ' ·후처리' : ''}${statusNote}
${(() => {
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 =
+ 'PC별 병렬 — 전략/조합마다 1·2차 (refine runner)
' +
+ 'MariaDB 공유 · PC마다 한 줄 · 2차만은 --phase1-study (DB payload)
' +
+ rows.map((row, i) => {
+ const done = row.phase1_db
+ ? ' (1차 DB OK) '
+ : (row.done ? ' (1차 OK) ' : '');
+ const note = row.phase2_note ? `${row.phase2_note}
` : '';
+ return `
+
+ ${row.step}. ${row.label || row.strategy}${done}
+
+ 1·2차 bash
+ 1·2차 PS
+ 2차만 bash
+ 2차만 PS
+
+
+ ${note}
+
+
+
`;
+ }).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 = '순차 스터디별 (이미 START 된 것만)
' +
+ box.innerHTML = '스터디별 trial 추가 (1·2차 study 이름)
' +
all.map((row, i) => {
const lab = [row.strategy, row.extra].filter(Boolean).join('/');
return `
- ${i + 1}. ${lab}
+ ${i + 1}. ${lab} · ${row.study || ''}
bash
PS
@@ -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 @@
새로고침
- 승리식 기본=수익·낙폭·표본 · (구)=PnL/max(MDD,하한) · min_trades: 꼬리=1 고정 · 그 외=거래일×2(기본) · 탐색 WR/PF=0 · 사후=results_gated · DB 자동적용 안 함 · 2개 이상=순차 · 이력소스=후보 테이블 · 꼬리: align/limit_atr · 돌파: fixed/atr×호가 off/on
+ 전략 1개만=1·2차 연쇄 · 2개 이상·꼬리2모드·돌파多=순차(조합마다 1·2차) · 승리식=수익·낙폭·표본 · min_trades: 꼬리=1 · 그 외=거래일×2 · WR/PF=0 · DB 미적용
진행
+
학습 trial
잡 —
study —
+
1·2차 —
trial —
@@ -4213,6 +4215,28 @@
+
+
사후합격 Top5 DB 적용 후보 · 일평균(원/%) · 익절/손절 비교
+
완료 후 표시
+
+
+
+
사후합격 pool 파라미터 분포 분포 참고용 · DB 적용 1순위 아님
+
+
범례 (막대 1줄 = 파라미터 1개)
+
+
+ 파란 띠 = p25~p75 · 사후합격 후보들의 중간 50% 구간 (너무 극단값 제외한 흔한 범위)
+
+ 녹색 세로줄 = median · 중앙값 (위·아래 절반이 이 값 기준)
+
+ 주황 동그라미 = mode · 최빈값 (후보 중 가장 많이 나온 숫자 · 괄호 %는 비율)
+
+
아래 숫자(p25·med·p75)는 막대와 동일. 익절·손절이 Top5마다 다르게 보이면 이 밴드가 넓다는 뜻입니다.
+
+
완료 후 표시
+
+
@@ -4272,7 +4296,8 @@
# trial 거래
승률 PF PnL
- 일평균
+ 일평균(원)
+ 일평균%
호가·익절·손절·휩쏘
손실일 최악일
안정점수 ↑
@@ -4281,7 +4306,7 @@
- 완료 후 표시
+ 완료 후 표시
@@ -4295,7 +4320,8 @@
# trial 거래
승률 PF PnL
- 일평균
+ 일평균(원)
+ 일평균%
호가·익절·손절·휩쏘
손실일 최악일
안정점수 ↑
@@ -4304,14 +4330,14 @@
- 완료 후 표시
+ 완료 후 표시
-
mode_combo 축별 최빈 조립 · trial 번호 없음 · 탐색 TopN 합의 1회 실측 — 표 컬럼=사후합격 Top10과 동일
+
mode_combo 축별 최빈 조립 · PnL 양수 pool · trial 번호 없음 · 2차 완료 후 표시
완료 후 표시
@@ -4319,7 +4345,8 @@
# trial 거래
승률 PF PnL
- 일평균
+ 일평균(원)
+ 일평균%
호가·익절·손절·휩쏘
손실일 최악일
안정점수 ↑
@@ -4328,7 +4355,33 @@
- 완료 후 표시 (mode 실측 1행 · 사후합격과 같은 열)
+ 완료 후 표시 (mode 실측 1행 · 사후합격과 같은 열)
+
+
+
+
+
+
+
mode Top10 results_mode · PnL 양수 pool p25~p75 밴드 근접도 ↑ · trial 있음 · 적용 1순위 아님
+
—
+
—
+
+
+
+
+ # trial 근접% ↑ 거래
+ 승률 PF PnL
+ 일평균(원)
+ 일평균%
+ 호가·익절·손절·휩쏘
+ 손실일 최악일
+ 안정점수 ↑
+ 과적합% ↓
+
+
+
+
+ 완료 후 표시 (mode Top10 · 구 JSON은 웹에서 즉시 재계산)
@@ -4343,7 +4396,8 @@
# trial 거래
승률 PF PnL
- 일평균
+ 일평균(원)
+ 일평균%
호가·익절·손절·휩쏘
손실일 최악일
안정점수 ↑
@@ -4352,24 +4406,10 @@
- 완료 후 표시 (구 JSON은 재실행 필요)
+ 완료 후 표시 (구 JSON은 재실행 필요)
-
-
후처리 비교표
- (사후합격「상세」· gated 없으면 학습Top learn# 폴백 · mode/live)
-
-
- 이 잡 후처리 재실행
- mode 상세
- 끝난 잡은 이 버튼이 켜집니다. 8방이 비면 누르세요(호가스냅 부족이면 재실행해도 동일).
-
-
합의: 완료 후 표시
-
-
사후합격 또는 안정 Top에서 「상세」를 누르면 이 표가 바뀝니다.
-
-
@@ -4412,24 +4452,30 @@
-
다른 PC에서 같은 study 붙이기
+ 다른 PC 병렬 · study 이어 붙이기
닫기
-
지금 study
+
+
지금 study (trial 추가용)
—
- Linux / VM (bash)
+ Linux / VM (bash) — 같은 study trial 추가
복사
—
- Windows PowerShell (이걸 복사)
+ Windows PowerShell — trial 추가
복사
—
- 웹이 실행한 명령 (순차=bash · 그대로 돌리면 새 study)
+ 순차 전체 (env 포함 bash · 이 VM과 동일)
+ 복사
+
+
—
+
+ 웹 실행 요약 (참고)
복사
—
@@ -4437,6 +4483,6 @@
-
+