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