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

+            

+          
`; + }).join(''); + rows.forEach((row, i) => { + const pf = document.getElementById('opt_refine_full_' + i); + if (pf) pf.textContent = row.cmd || ''; + const p2 = document.getElementById('opt_refine_p2_' + i); + if (p2) p2.textContent = row.cmd_phase2 || ''; + }); + } + } const box = $('opt_join_all'); if (box) { const all = job.join_cmds_all || []; @@ -10198,11 +10646,11 @@ function optunaFillJoinOverlay(job) { return; } window._optunaJoinAll = all; - box.innerHTML = '
순차 스터디별 (이미 START 된 것만)
' + + box.innerHTML = '
스터디별 trial 추가 (1·2차 study 이름)
' + all.map((row, i) => { const lab = [row.strategy, row.extra].filter(Boolean).join('/'); return `
- ${i + 1}. ${lab} + ${i + 1}. ${lab} · ${row.study || ''} @@ -10220,6 +10668,16 @@ function optunaFillJoinOverlay(job) { } } +function optunaCopySeqRefine(i, which) { + const row = (window._optunaSeqRefine || [])[i]; + if (!row) return; + let t = row.cmd || ''; + if (which === 'full_ps') t = row.cmd_ps || ''; + if (which === 'p2') t = row.cmd_phase2 || ''; + if (which === 'p2_ps') t = row.cmd_phase2_ps || ''; + optunaCopyText(t); +} + function optunaCopyText(text) { const t = String(text || ''); if (!t) return; @@ -10239,6 +10697,7 @@ function optunaCopyJoinAll(i, which) { function optunaCopyJoin(which) { let el = $('opt_join_cmd'); if (which === 'web') el = $('opt_web_cmd'); + if (which === 'web_full') el = $('opt_web_cmd_full'); if (which === 'ps') el = $('opt_join_cmd_ps'); optunaCopyText(el ? el.textContent : ''); } diff --git a/templates/backtest.html b/templates/backtest.html index 501926a..2b3ef77 100644 --- a/templates/backtest.html +++ b/templates/backtest.html @@ -4166,11 +4166,12 @@
- 승리식 기본=수익·낙폭·표본 · (구)=PnL/max(MDD,하한) · min_trades: 꼬리=1 고정 · 그 외=거래일×2(기본) · 탐색 WR/PF=0 · 사후=results_gated · DB 자동적용 안 함 · 2개 이상=순차 · 이력소스=후보 테이블 · 꼬리: align/limit_atr · 돌파: fixed/atr×호가 off/on + 전략 1개만=1·2차 연쇄 · 2개 이상·꼬리2모드·돌파多=순차(조합마다 1·2차) · 승리식=수익·낙폭·표본 · min_trades: 꼬리=1 · 그 외=거래일×2 · WR/PF=0 · DB 미적용
진행
+
학습 trial
@@ -4187,6 +4188,7 @@
study
+
1·2차
trial
@@ -4213,6 +4215,28 @@
+
+
사후합격 Top5 DB 적용 후보 · 일평균(원/%) · 익절/손절 비교
+
완료 후 표시
+
+ +
+
사후합격 pool 파라미터 분포 분포 참고용 · DB 적용 1순위 아님
+
+
범례 (막대 1줄 = 파라미터 1개)
+
+ + 파란 띠 = p25~p75 · 사후합격 후보들의 중간 50% 구간 (너무 극단값 제외한 흔한 범위) + + 녹색 세로줄 = median · 중앙값 (위·아래 절반이 이 값 기준) + + 주황 동그라미 = mode · 최빈값 (후보 중 가장 많이 나온 숫자 · 괄호 %는 비율) +
+
아래 숫자(p25·med·p75)는 막대와 동일. 익절·손절이 Top5마다 다르게 보이면 이 밴드가 넓다는 뜻입니다.
+
+
완료 후 표시
+
+
@@ -4272,7 +4296,8 @@ #trial거래 승률PFPnL - 일평균 + 일평균(원) + 일평균% 호가·익절·손절·휩쏘 손실일최악일 안정점수 ↑ @@ -4281,7 +4306,7 @@ - 완료 후 표시 + 완료 후 표시
@@ -4295,7 +4320,8 @@ #trial거래 승률PFPnL - 일평균 + 일평균(원) + 일평균% 호가·익절·손절·휩쏘 손실일최악일 안정점수 ↑ @@ -4304,14 +4330,14 @@ - 완료 후 표시 + 완료 후 표시
-
mode_combo 축별 최빈 조립 · trial 번호 없음 · 탐색 TopN 합의 1회 실측 — 표 컬럼=사후합격 Top10과 동일
+
mode_combo 축별 최빈 조립 · PnL 양수 pool · trial 번호 없음 · 2차 완료 후 표시
완료 후 표시
@@ -4319,7 +4345,8 @@ - + + @@ -4328,7 +4355,33 @@ - + + +
#trial거래 승률PFPnL일평균일평균(원)일평균% 호가·익절·손절·휩쏘 손실일최악일 안정점수 ↑
완료 후 표시 (mode 실측 1행 · 사후합격과 같은 열)
완료 후 표시 (mode 실측 1행 · 사후합격과 같은 열)
+
+
+ +
+
mode Top10 results_mode · PnL 양수 pool p25~p75 밴드 근접도 ↑ · trial 있음 · 적용 1순위 아님
+
+
+
+ + + + + + + + + + + + + + + +
#trial근접% ↑거래승률PFPnL일평균(원)일평균%호가·익절·손절·휩쏘손실일최악일안정점수 ↑과적합% ↓
완료 후 표시 (mode Top10 · 구 JSON은 웹에서 즉시 재계산)
@@ -4343,7 +4396,8 @@ #trial거래 승률PFPnL - 일평균 + 일평균(원) + 일평균% 호가·익절·손절·휩쏘 손실일최악일 안정점수 ↑ @@ -4352,24 +4406,10 @@ - 완료 후 표시 (구 JSON은 재실행 필요) + 완료 후 표시 (구 JSON은 재실행 필요)
-
-
후처리 비교표 - (사후합격「상세」· gated 없으면 학습Top learn# 폴백 · mode/live) -
-
- - - 끝난 잡은 이 버튼이 켜집니다. 8방이 비면 누르세요(호가스냅 부족이면 재실행해도 동일). -
-
합의: 완료 후 표시
-
-
사후합격 또는 안정 Top에서 「상세」를 누르면 이 표가 바뀝니다.
-
-
@@ -4412,24 +4452,30 @@
- + \ No newline at end of file