From 0780b2cdd041c50a3d5e85b9e6a2aefa624e2c37 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 21 Aug 2026 19:05:23 +0900 Subject: [PATCH] feat: Enhance Optuna integration and logging for backtesting framework Changes: - Added new API endpoints for continuing and confirming Optuna jobs, allowing for better management of ongoing studies. - Introduced detailed logging for tick feed tracking and order book processing, improving traceability of vendor performance during backtests. - Updated database schema to include new fields for managing Optuna study results, enhancing the ability to track study progress and outcomes. - Refactored existing functions to utilize the new logging and tracking features, ensuring consistency across the backtesting framework. Impact: - These enhancements improve the robustness and transparency of the Optuna backtesting process, facilitating better analysis and optimization of trading strategies. --- __pycache__/database.cpython-312.pyc | Bin 241727 -> 245323 bytes backtest_web.py | 57 +- database.py | 52 +- docs/like_mcp.md/MODIFICATION_GUIDE.md | 13 +- docs/like_mcp.md/code_architecture.md | 5 +- docs/like_mcp.md/db_erd.md | 77 +- docs/옵투나.md | 4 + .../backtest/breakout_backtest_common.py | 20 +- kis_trader/backtest/breakout_tick_loader.py | 15 + kis_trader/backtest/bt_candle_source.py | 238 +++++- .../backtest/momentum_backtest_common.py | 20 +- .../backtest/momentum_portfolio_backtest.py | 13 + kis_trader/backtest/optuna_breakout.py | 39 +- kis_trader/backtest/optuna_common.py | 94 ++- kis_trader/backtest/optuna_feed_trace.py | 369 ++++++++ kis_trader/backtest/optuna_momentum.py | 42 +- .../backtest/optuna_orderbook_recommend.py | 90 +- .../backtest/optuna_postprocess_topn.py | 200 ++++- .../backtest/optuna_rerun_postprocess.py | 27 + kis_trader/backtest/optuna_scalping.py | 39 +- kis_trader/backtest/optuna_study_store.py | 700 +++++++++++++++ kis_trader/backtest/optuna_web_jobs.py | 722 ++++++++++++++-- kis_trader/backtest/param_search_momentum.py | 18 +- kis_trader/backtest/param_search_optuna.py | 54 +- ...a_breakout_tpe_20260821_114315.briefing.md | 46 + ...a_breakout_tpe_20260821_120015.briefing.md | 46 + ...a_momentum_tpe_20260820_121708.briefing.md | 46 + ...a_momentum_tpe_20260821_104243.briefing.md | 45 + ...a_momentum_tpe_20260821_151059.briefing.md | 46 + ...tuna_scalp_tpe_20260821_122053.briefing.md | 46 + ...ptuna_tail_tpe_20260821_111355.briefing.md | 45 + ...ptuna_tail_tpe_20260821_113102.briefing.md | 46 + .../backtest/scalping_backtest_common.py | 18 +- kis_trader/backtest/tail_backtest_common.py | 52 +- .../backtest/trigger_snapshot_loader.py | 8 + kis_trader/engine/feed_fallback.py | 172 ++++ kis_trader/engine/live_sell_price.py | 104 +++ kis_trader/engine/momentum_tick_replay.py | 19 +- kis_trader/engine/tail_tick_replay.py | 24 +- kis_trader/execution/order_manager.py | 108 +-- kis_trader/network/ws_manager.py | 178 +++- kis_trader/strategies/base.py | 56 +- kis_trader/strategies/breakout.py | 24 +- kis_trader/strategies/momentum.py | 9 +- kis_trader/strategies/scalping.py | 9 +- kis_trader/strategies/tail_catch.py | 8 +- kis_trader/web/live_config_schema.py | 48 +- kis_trader/ws/candle_series.py | 82 +- kis_trader/ws/kis_ws.py | 2 + kis_trader/ws/kiwoom_ws.py | 7 + kis_trader/ws/tick_recorder.py | 2 + logs/kis_api_rejects.jsonl | 4 + logs/opt_20260821_102454_seq_seq_active.txt | 5 + logs/optuna_4strat_tpe_latest_master.logpath | 2 +- logs/optuna_breakout_latest.jsonpath | 2 +- logs/optuna_breakout_tpe_latest.jsonpath | 2 +- logs/optuna_breakout_tpe_latest.logpath | 2 +- logs/optuna_breakout_tpe_latest.study | 2 +- logs/optuna_latest.jsonpath | 2 +- logs/optuna_momentum_latest.jsonpath | 2 +- logs/optuna_momentum_tpe_latest.jsonpath | 2 +- logs/optuna_momentum_tpe_latest.logpath | 2 +- logs/optuna_momentum_tpe_latest.study | 2 +- logs/optuna_scalp_latest.jsonpath | 2 +- logs/optuna_scalp_tpe_latest.jsonpath | 2 +- logs/optuna_scalp_tpe_latest.logpath | 2 +- logs/optuna_scalp_tpe_latest.study | 2 +- logs/optuna_tail_latest.jsonpath | 2 +- logs/optuna_tail_tpe_latest.jsonpath | 2 +- logs/optuna_tail_tpe_latest.logpath | 2 +- logs/optuna_tail_tpe_latest.study | 2 +- logs/optuna_web_latest_job.txt | 2 +- scripts/run_optuna_4strat_tpe_seq.sh | 17 +- static/css/backtest.css | 34 + static/js/backtest.js | 795 +++++++++++++++--- templates/backtest.html | 67 +- 76 files changed, 4648 insertions(+), 516 deletions(-) create mode 100644 kis_trader/backtest/optuna_feed_trace.py create mode 100644 kis_trader/backtest/optuna_study_store.py create mode 100644 kis_trader/backtest/results/optuna_breakout_tpe_20260821_114315.briefing.md create mode 100644 kis_trader/backtest/results/optuna_breakout_tpe_20260821_120015.briefing.md create mode 100644 kis_trader/backtest/results/optuna_momentum_tpe_20260820_121708.briefing.md create mode 100644 kis_trader/backtest/results/optuna_momentum_tpe_20260821_104243.briefing.md create mode 100644 kis_trader/backtest/results/optuna_momentum_tpe_20260821_151059.briefing.md create mode 100644 kis_trader/backtest/results/optuna_scalp_tpe_20260821_122053.briefing.md create mode 100644 kis_trader/backtest/results/optuna_tail_tpe_20260821_111355.briefing.md create mode 100644 kis_trader/backtest/results/optuna_tail_tpe_20260821_113102.briefing.md create mode 100644 logs/opt_20260821_102454_seq_seq_active.txt diff --git a/__pycache__/database.cpython-312.pyc b/__pycache__/database.cpython-312.pyc index d60414de31a239d5b6ac5165bf0f1c524d766e4e..dd336205b267f36dd331c3a39418b02368037b33 100644 GIT binary patch delta 18395 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 delta 15776 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 diff --git a/backtest_web.py b/backtest_web.py index 27f3507..4739956 100644 --- a/backtest_web.py +++ b/backtest_web.py @@ -471,16 +471,21 @@ def _finalize_trades_ui( def _momentum_source_label(kind: str, src: str) -> str: s = str(src or "").strip().lower() + # ws_ticks:kis / ws_ticks:kiwoom — 옵투나·백테 벤더 추적 + vendor = "" + if s.startswith("ws_ticks:"): + vendor = s.split(":", 1)[1].strip() + s = "ws_ticks" if kind == "entry": if s in ("ws_ticks", "tick"): - return "틱진입" + return f"틱진입({vendor})" if vendor else "틱진입" if s in ("ohlc_open", "open"): return "시가" if s in ("ohlc_low", "ohlc_fallback", "ohlc_high", "ohlc_intrabar"): return "진입OHLC" return s or "-" if s in ("ws_ticks", "tick"): - return "틱청산" + return f"틱청산({vendor})" if vendor else "틱청산" if s in ("ohlc_bar", "ohlc_fallback"): return "청산OHLC" if s in ("eod_flat", "wallclock_eod", "wallclock_last"): @@ -9372,6 +9377,18 @@ def api_optuna_start(): trials = int(body.get("trials") or request.form.get("trials") or 200) except (TypeError, ValueError): trials = 200 + study_trials = None + raw_st = body.get("study_trials") + if raw_st is None: + raw_st = request.form.get("study_trials") + try: + if raw_st is not None and str(raw_st).strip() != "": + study_trials = int(raw_st) + except (TypeError, ValueError): + study_trials = None + study_name_override = ( + body.get("study_name") or request.form.get("study_name") or "" + ).strip() or None try: meta = owj.start_optuna_job( strategy=strategy or None, @@ -9387,6 +9404,8 @@ def api_optuna_start(): ob_source=ob_src or None, entry_modes=entry_modes, sl_modes=sl_modes, + study_trials=study_trials, + study_name_override=study_name_override, ) return jsonify({"ok": True, "job": meta}) except Exception as e: @@ -9491,6 +9510,40 @@ def api_optuna_active(): }) +@app.route("/api/optuna/continue", methods=["POST"]) +def api_optuna_continue(): + """같은 study 로 남은 횟수 웹 잡.""" + from kis_trader.backtest import optuna_web_jobs as owj + + body = request.get_json(silent=True) or {} + job_id = (body.get("job_id") or "").strip() + if not job_id: + return jsonify({"ok": False, "error": "job_id 필요"}), 400 + try: + meta = owj.continue_optuna_job(job_id) + return jsonify({"ok": True, "job": meta}) + except Exception as e: + logger.warning("optuna continue failed: %s", e) + return jsonify({"ok": False, "error": str(e)}), 400 + + +@app.route("/api/optuna/confirm", methods=["POST"]) +def api_optuna_confirm(): + """현재 완료 수로 목표 확정 + 후처리.""" + from kis_trader.backtest import optuna_web_jobs as owj + + body = request.get_json(silent=True) or {} + job_id = (body.get("job_id") or "").strip() + if not job_id: + return jsonify({"ok": False, "error": "job_id 필요"}), 400 + try: + out = owj.confirm_optuna_study(job_id) + return jsonify(out if isinstance(out, dict) else {"ok": True, "job": out}) + except Exception as e: + logger.warning("optuna confirm failed: %s", e) + return jsonify({"ok": False, "error": str(e)}), 400 + + @app.route("/api/optuna/stop/", methods=["POST"]) def api_optuna_stop(job_id: str): from kis_trader.backtest import optuna_web_jobs as owj diff --git a/database.py b/database.py index 46cff78..6fd9cab 100644 --- a/database.py +++ b/database.py @@ -577,6 +577,10 @@ ENV_CONFIG_KEYS = ( "WS_PRICE_MAX_AGE_SEC", # 실매·옵투나 읽기 폴백 나이(초). 메인 체결시각 vs 지금. 넘기면 그 벤더 실패→2차→3차. "LIVE_FEED_FALLBACK_MAX_AGE_SEC", + "TRIGGER_FEED_DETAIL_LOG", + # 옵투나/백테 피드 추적 (틱·호가 벤더 비율 + TRIGGER 샘플 상한) + "BT_FEED_DETAIL_LOG", + "BT_FEED_DETAIL_LOG_MAX", "CANDLE_GARBAGE_FALLBACK", # LS에 메인 틱/호가 유니버스(후보∪보유∪grace) 구독. 영구와 별개 owner. "LS_FEED_FALLBACK_SUBSCRIBE", @@ -587,6 +591,7 @@ ENV_CONFIG_KEYS = ( # 실매 매도: 마지막 WS 체결가 유지. 죽은선 감지 시에만 REST 1회(쿨다운). "SELL_WS_STALE_REST_SEC", "SELL_WS_STALE_REST_COOLDOWN_SEC", + "SELL_WS_LAST_RAM_MAX_AGE_SEC", "WS_PROGRAM_COLLECT_ENABLED", "WS_TRIGGER_EVAL_DB_BATCH_SIZE", "WS_TRIGGER_EVAL_DB_FLUSH_SEC", @@ -1900,6 +1905,37 @@ class TradeDB: updated_at VARCHAR(30) NOT NULL ) CHARACTER SET utf8mb4 """) + # 8b-2. Optuna 스터디별 웹 결과·후처리 (kis_optuna 공식 테이블과 분리) + self.conn.execute_long(""" + CREATE TABLE IF NOT EXISTS optuna_study_result ( + study_name VARCHAR(255) NOT NULL COMMENT 'Optuna study 이름 (kis_optuna 과 동일)', + strategy VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'tail|momentum|us_momentum|breakout|scalp', + mode VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'tpe 등', + start_date VARCHAR(10) NULL COMMENT '백테 시작 YYYY-MM-DD', + end_date VARCHAR(10) NULL COMMENT '백테 종료 YYYY-MM-DD', + job_id VARCHAR(64) NULL COMMENT '웹 잡 id (참고)', + symbol VARCHAR(16) NULL COMMENT '해외 종목핀일 때만', + study_trials INT NOT NULL DEFAULT 0 COMMENT '스터디 총 완료 목표. 0이면 미설정', + n_complete INT NOT NULL DEFAULT 0 COMMENT 'kis_optuna 완료 trial 수 캐시', + n_running INT NOT NULL DEFAULT 0 COMMENT '진행 중 trial 수 캐시 (표시용)', + payload_json LONGTEXT NULL COMMENT 'results_all/gated/stable, mode_combo, postprocess_topn, overfit (웹 요약과 동일 키)', + briefing_md MEDIUMTEXT NULL COMMENT '브리핑 마크다운', + pp_needed TINYINT NOT NULL DEFAULT 0 COMMENT '1=목표 도달, 후처리 대기', + pp_status VARCHAR(16) NOT NULL DEFAULT 'idle' COMMENT 'idle|running|done|error', + pp_for_n INT NOT NULL DEFAULT 0 COMMENT '이 후처리가 반영한 n_complete', + pp_lock_until DATETIME NULL COMMENT '락 만료 (죽은 워커 복구)', + pp_lock_owner VARCHAR(64) NULL COMMENT '잠근 호스트/pid', + pp_error VARCHAR(500) NULL COMMENT '후처리 실패 메시지', + leftover_note VARCHAR(200) NULL COMMENT '웹 남은횟수 안내 캐시', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '행 갱신', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '최초 생성', + PRIMARY KEY (study_name), + KEY idx_pp_needed (pp_needed, pp_status), + KEY idx_job (job_id), + KEY idx_updated (updated_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='Optuna 스터디별 웹 결과·후처리 산출 (파일 JSON 대체)' + """, timeout_sec=120) # 8c. 인증/계좌/ID 전용 컬럼 저장소 (앱키/시크릿/ID 분리) acols = ", ".join([f"`{k}` TEXT" for k in ENV_AUTH_KEYS]) self.conn.execute(""" @@ -1963,6 +1999,7 @@ class TradeDB: session VARCHAR(8) DEFAULT NULL, currency VARCHAR(8) NOT NULL DEFAULT 'KRW', source VARCHAR(16) NOT NULL DEFAULT 'kis', + channel VARCHAR(10) NOT NULL DEFAULT 'ws', recv_ts VARCHAR(30) NOT NULL, KEY idx_ws_ticks_lookup (market, code, tick_time), KEY idx_ws_ticks_recv (recv_ts) @@ -1983,11 +2020,14 @@ class TradeDB: session VARCHAR(8) DEFAULT NULL, currency VARCHAR(8) NOT NULL DEFAULT 'USD', source VARCHAR(16) NOT NULL DEFAULT 'kis_us', + channel VARCHAR(10) NOT NULL DEFAULT 'ws', recv_ts VARCHAR(30) NOT NULL, KEY idx_ws_ticks_us_lookup (market, code, tick_time), KEY idx_ws_ticks_us_recv (recv_ts) ) ENGINE=InnoDB CHARACTER SET utf8mb4 """) + self._migrate_ws_ticks_extra_columns("ws_ticks") + self._migrate_ws_ticks_extra_columns("ws_ticks_us") # 11b. TRIGGER 호가 스냅샷 (키움 0D — 백테·파람서치 재현) self.conn.execute(""" @@ -4830,6 +4870,7 @@ class TradeDB: session VARCHAR(8) DEFAULT NULL, currency VARCHAR(8) NOT NULL DEFAULT 'KRW', source VARCHAR(16) NOT NULL DEFAULT 'kis', + channel VARCHAR(10) NOT NULL DEFAULT 'ws', recv_ts VARCHAR(30) NOT NULL, cntr_str DOUBLE DEFAULT NULL, upper_limit_time VARCHAR(8) DEFAULT NULL, @@ -4858,6 +4899,8 @@ class TradeDB: ("cntr_str", "DOUBLE DEFAULT NULL"), ("upper_limit_time", "VARCHAR(8) DEFAULT NULL"), ("tick_time_raw", "VARCHAR(64) DEFAULT NULL"), + # 봉 ws_candles 와 동일: source=증권사, channel=경로(ws|rest). 틱은 WS만 적재 → 기본 ws. + ("channel", "VARCHAR(10) NOT NULL DEFAULT 'ws' COMMENT 'ws|rest' AFTER `source`"), ): if col not in existing: try: @@ -4896,6 +4939,7 @@ class TradeDB: session VARCHAR(8) DEFAULT NULL, currency VARCHAR(8) NOT NULL DEFAULT 'USD', source VARCHAR(16) NOT NULL DEFAULT 'kis_us', + channel VARCHAR(10) NOT NULL DEFAULT 'ws', recv_ts VARCHAR(30) NOT NULL, cntr_str DOUBLE DEFAULT NULL, upper_limit_time VARCHAR(8) DEFAULT NULL, @@ -4927,6 +4971,9 @@ class TradeDB: return 0 rows = [] for it in batch: + ch = str(it.get("channel") or "ws").strip().lower() or "ws" + if ch not in ("ws", "rest"): + ch = "ws" rows.append(( (it.get("market") or default_market)[:8], (it.get("exchange") or None), @@ -4938,6 +4985,7 @@ class TradeDB: (it.get("session") or None), (it.get("currency") or default_currency)[:8], (it.get("source") or default_source)[:16], + ch[:10], it.get("recv_ts") or datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), float(it["cntr_str"]) if it.get("cntr_str") is not None else None, (it.get("upper_limit_time") or None), @@ -4945,7 +4993,7 @@ class TradeDB: )) exec_conn = conn or self.conn try: - ph = "(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)" + ph = "(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)" placeholders = ", ".join([ph] * len(rows)) flat: List[Any] = [] for row in rows: @@ -4954,7 +5002,7 @@ class TradeDB: f""" INSERT INTO {table} (market, exchange, code, tick_time, price, volume, - tick_seq, session, currency, source, recv_ts, + tick_seq, session, currency, source, channel, recv_ts, cntr_str, upper_limit_time, tick_time_raw) VALUES {placeholders} """, diff --git a/docs/like_mcp.md/MODIFICATION_GUIDE.md b/docs/like_mcp.md/MODIFICATION_GUIDE.md index 1305bd7..b2fe212 100644 --- a/docs/like_mcp.md/MODIFICATION_GUIDE.md +++ b/docs/like_mcp.md/MODIFICATION_GUIDE.md @@ -77,15 +77,19 @@ align 스터디는 `limit_atr_mult` 제외. fixed 스터디는 `atr_sl_*` 제외 유령정리: `INQUIRE_PSBL_RVSECNCL_BEFORE_GHOST` · 잔고는 `hldg_qty`(보유)와 `ord_psbl_qty`(매도가능) 분리. 매도가능 0 ≠ ghost. 시세·호가 읽기 폴백 (실매=옵투나 같은 읽기): +- `LIVE_TICK_PROVIDER` / `LIVE_OB_PROVIDER` 운영설정 **셀박** (`kiwoom`|`kis`). LS는 1차 아님(3차 spill). - `LIVE_FEED_FALLBACK_MAX_AGE_SEC`(기본 3) = **그 틱의 체결시각(FID20/chetime) vs 이 서버 지금**. 1·2·3차 동일. 벤더끼리 시각 비교 금지. +- `TRIGGER_FEED_DETAIL_LOG`(기본 true) = 매수체크 `🔍` 로그에 틱1차설정/실제(kis|kiwoom|ls)·px·틱타임·호가 bid/ask/or 꼬리. +- `BT_FEED_DETAIL_LOG`(기본 true) / `BT_FEED_DETAIL_LOG_MAX`(기본 40) = 옵투나·백테에도 동일 축: 틱/호가 로드 벤더 비율 INFO + TRIGGER 샘플. `entry_source=ws_ticks:kis` 등. +- 호가 후처리(`recommend_orderbook_parameters`): 코어 TPE 호가OFF와 무관. `[호가후처리]` DB벤더·`[호가후처리샘플]` bid/ask/src 가 **후처리 구간**에 나옴. - 분봉 쓰레기=`CANDLE_GARBAGE_FALLBACK`(기본 true). 그 분 그 소스 틱 0건이거나 전부 **봉 끝시각** 대비 읽기나이 초과면 그 WS 봉을 구멍 → 다음 소스 **봉 통째**. 한 봉 안 키움+한투 혼합 금지. 실매 링이 그 소스·그 분을 커버 못하면 유지. - 한투 호가=`WS_ORDERBOOK_SAVE_KIS`: **2번째 앱키(`KIS_APP_KEY_OB_REAL`) 전용 세션만**. 키 없거나 start 실패 시 메인에 H0STASP0 붙이지 않음(시세 41 합산 금지). - `LS_FEED_FALLBACK_SUBSCRIBE`(기본 true) → 후보∪보유∪영구∪grace 를 LS RAM 미러. **MINIMAL ON/OFF 무관** (`sync_targets` + split reconcile 둘 다). DB 틱 `should_persist_ls`(영구만) 이번 범위 밖. -- 키움 RAM/리스너/봉 skip = 읽기 나이. `ws_ticks` 적재는 기본 전부(`KIWOOM_TICK_LIVE_MAX_LAG_SEC=0`). 같이 내리지 말 것. +- 키움 RAM/리스너/봉 skip = 읽기 나이. `ws_ticks` 적재는 기본 전부(`KIWOOM_TICK_LIVE_MAX_LAG_SEC=0`). 같이 내리지 말 것. `ws_ticks.channel`(기본 ws)은 봉과 같은 경로 라벨. ka10007을 틱 INSERT 하지 말 것. - 시세 REST 키 = `KIWOOM_WS_FORCE_REAL`(기본 true) 실키. `_get_kiwoom_creds` 가 `KIS_MOCK` 모의키로 가면 안 됨 (8001). - `LS_GAP_FILL_CANDIDATES` 기본 OFF. - `LS_WS_TICK_SAVE` 기본 false = `ls_ws_ticks` INSERT 만 OFF. `_tick_recorder` 를 같이 끄면 호가 틱동기도 0건. `LS_WS_ALSO_HOGA` 기본 true(UH1 구독). `LS_WS_ORDERBOOK_SAVE` 기본 true(구독 종목). 호가필터 `FILTER_MAX_AGE=0` 을 저장 TTL 과 다시 합치지 말 것. -- 매수 루프 전 종목 REST 금지. 매도 4차만 키움 `ka10007` + `SELL_WS_STALE_REST_COOLDOWN_SEC`. 한투 60초 캐시 금지. +- 매수 루프 전 종목 REST 금지. 매도: 3초 체인 → last-RAM(`SELL_WS_LAST_RAM_MAX_AGE_SEC`) → 직전가 캐시 → 4차 키움 `ka10007`+쿨다운. 한투 60초 캐시 금지. 보유 KIS는 41슬롯 pin + `WS_TICK_GRACE_SEC`(후보 KIS grace 아님). - MM 체결 알림: `시세: kiwoom(1차) | 호가: ls(3차)`. **확인 후 수정해야 할 곳 체크리스트**: @@ -257,6 +261,9 @@ HTS는 후보 유니버스 참고용. - momentum_backtest_candle_warmup_bars() ← momentum_backtest_common.py - scalp_backtest_candle_warmup_bars() ← scalping_backtest_common.py - tail_backtest_candle_warmup_bars() ← tail_backtest_common.py +기간 봉 로드는 fetch_ws_candles_by_code_bulk (종목 for 금지). +웜업 prepend 만 fetch_ws_candles_warmup_before (종목당). +CANDLE_GARBAGE_FALLBACK 끄지 말 것. history_source=ls 는 별도. ``` ### env 키 저장 경로 @@ -268,7 +275,7 @@ Optuna 후처리(실매 엔진 비영향): OPTUNA_POST_TOP_N / INCLUDE_MODE / IN OB·WHIPSAW trial 수 · OPTUNA_OB_COMBO_TRIALS_* · OPTUNA_OB_AXIS_TRIALS · 진입/익절/STOP 탐색 범위 — ensure_optuna_gate_env_defaults → env_config_ext. 모듈: kis_trader/backtest/optuna_postprocess_topn.py · optuna_orderbook_recommend.py - · optuna_rerun_postprocess.py (구 JSON 「이 잡 후처리 재실행」) + · optuna_rerun_postprocess.py (구 JSON 「이 잡 후처리 재실행」. 호가스냅<3 `not_enough_trades` 는 구JSON이 아님 — 재실행해도 스냅 없으면 8방 동일) 후처리 앵커: gated TopN + stable TopN(표/적용) + mode + live(참고). 합의·과적합 가점은 gated+mode만. 웹 Optuna: 학습/gated/stable Top5 행에 안정점수(↑·만점없음·원) + 과적합%(↓·0~100). 1위 비교표·mode_combo 실측행은 표시 안 함. 진입 격자(2026-08-15): OPTUNA_OB_ENTRY_SPREAD 0.1~8 / RATIO 0.05~1.5 / ASK_MULT 1~80 / LOOKBACK 30분. diff --git a/docs/like_mcp.md/code_architecture.md b/docs/like_mcp.md/code_architecture.md index 2141aba..10e1609 100644 --- a/docs/like_mcp.md/code_architecture.md +++ b/docs/like_mcp.md/code_architecture.md @@ -60,6 +60,7 @@ graph TD C[momentum_backtest_common.py] C2[param_search_momentum.py] C3[optuna_momentum.py] + C4[bt_candle_source.py] end subgraph "🗄️ 데이터 레이어" D[(kis_quant_db)] @@ -76,6 +77,7 @@ graph TD B --> B4 B --> C C2 --> C3 + C --> C4 C --> D2 B --> D2 D2 --> D @@ -237,7 +239,8 @@ graph TD | `optuna_rerun_postprocess.py` | — | 🔬 백테스트 | `optuna_postprocess_topn`, `optuna_momentum` | | `optuna_search_space.py` | 89 | 🔬 백테스트 | `param_search_breakout`, `param_search_momentum`, `param_search_scalping` +1 | | `optuna_tpe_common.py` | 218 | 🔬 백테스트 | | -| `optuna_web_jobs.py` | 1,121 | 🔬 백테스트 | `backtest_portfolio_common`, `optuna_common`, `optuna_daily_trail_recommend` +6 | +| `optuna_web_jobs.py` | 1,121 | 🔬 백테스트 | `backtest_portfolio_common`, `optuna_common`, `optuna_study_store` +6 | +| `optuna_study_store.py` | — | 🔬 백테스트 | `optuna_study_result` 테이블 (JSON 대체, 총 횟수 후처리 게이트) | | `param_apply_ai.py` | 314 | 🔬 백테스트 | `database`, `backtest_portfolio_common` | | `param_search_apply_snapshot.py` | 648 | 🔬 백테스트 | `database`, `backtest`, `backtest_portfolio_common` +4 | | `param_search_cli_common.py` | 110 | 🔬 백테스트 | | diff --git a/docs/like_mcp.md/db_erd.md b/docs/like_mcp.md/db_erd.md index 707ae0c..71ebf3c 100644 --- a/docs/like_mcp.md/db_erd.md +++ b/docs/like_mcp.md/db_erd.md @@ -22,6 +22,7 @@ - [📰 DART 공시](#-dart-공시) - [🤖 AI 분석](#-ai-분석) - [📋 주문 실행 로그](#-주문-실행-로그) +- [🔬 Optuna 스터디 결과](#-optuna-스터디-결과) --- @@ -3269,6 +3270,74 @@ CREATE TABLE `kv_store` ( +### 🔬 Optuna 스터디 결과 + +kis_optuna 의 `studies`/`trials` 와 분리. 웹 Top5·후처리 산출은 `kis_quant_db.optuna_study_result`. + +#### `optuna_study_result` + +**PK**: `study_name` + +| 컬럼 | 타입 | NULL | DEFAULT | 비고 | +|------|------|------|---------|------| +| `study_name` 🔑 | `varchar(255)` | NOT NULL | | Optuna study 이름 (kis_optuna 과 동일) | +| `strategy` | `varchar(32)` | NOT NULL | '' | tail\|momentum\|us_momentum\|breakout\|scalp | +| `mode` | `varchar(32)` | NOT NULL | '' | tpe 등 | +| `start_date` | `varchar(10)` | NULL | NULL | 백테 시작 YYYY-MM-DD | +| `end_date` | `varchar(10)` | NULL | NULL | 백테 종료 YYYY-MM-DD | +| `job_id` | `varchar(64)` | NULL | NULL | 웹 잡 id (참고) | +| `symbol` | `varchar(16)` | NULL | NULL | 해외 종목핀일 때만 | +| `study_trials` | `int` | NOT NULL | 0 | 스터디 총 완료 목표. 0이면 미설정 | +| `n_complete` | `int` | NOT NULL | 0 | 완료 trial 수 캐시 | +| `n_running` | `int` | NOT NULL | 0 | 진행 중 trial 수 캐시 | +| `payload_json` | `longtext` | NULL | NULL | 웹 요약과 동일 키 (results_*/mode_combo/postprocess) | +| `briefing_md` | `mediumtext` | NULL | NULL | 브리핑 마크다운 | +| `pp_needed` | `tinyint` | NOT NULL | 0 | 1=목표 미달(이어 돌리기/확정) | +| `pp_status` | `varchar(16)` | NOT NULL | idle | idle\|running\|done\|error | +| `pp_for_n` | `int` | NOT NULL | 0 | 이 후처리가 반영한 n_complete | +| `pp_lock_until` | `datetime` | NULL | NULL | 락 만료 | +| `pp_lock_owner` | `varchar(64)` | NULL | NULL | 잠근 호스트/pid | +| `pp_error` | `varchar(500)` | NULL | NULL | 후처리 실패 메시지 | +| `leftover_note` | `varchar(200)` | NULL | NULL | 남은횟수 안내 | +| `updated_at` | `datetime` | NOT NULL | CURRENT_TIMESTAMP | 행 갱신 | +| `created_at` | `datetime` | NOT NULL | CURRENT_TIMESTAMP | 최초 생성 | + +
+DDL 원문 + +```sql +CREATE TABLE IF NOT EXISTS optuna_study_result ( + study_name VARCHAR(255) NOT NULL COMMENT 'Optuna study 이름 (kis_optuna 과 동일)', + strategy VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'tail|momentum|us_momentum|breakout|scalp', + mode VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'tpe 등', + start_date VARCHAR(10) NULL COMMENT '백테 시작 YYYY-MM-DD', + end_date VARCHAR(10) NULL COMMENT '백테 종료 YYYY-MM-DD', + job_id VARCHAR(64) NULL COMMENT '웹 잡 id (참고)', + symbol VARCHAR(16) NULL COMMENT '해외 종목핀일 때만', + study_trials INT NOT NULL DEFAULT 0 COMMENT '스터디 총 완료 목표. 0이면 미설정', + n_complete INT NOT NULL DEFAULT 0 COMMENT 'kis_optuna 완료 trial 수 캐시', + n_running INT NOT NULL DEFAULT 0 COMMENT '진행 중 trial 수 캐시 (표시용)', + payload_json LONGTEXT NULL COMMENT 'results_all/gated/stable, mode_combo, postprocess_topn, overfit', + briefing_md MEDIUMTEXT NULL COMMENT '브리핑 마크다운', + pp_needed TINYINT NOT NULL DEFAULT 0 COMMENT '1=목표 도달 전 후처리 대기', + pp_status VARCHAR(16) NOT NULL DEFAULT 'idle' COMMENT 'idle|running|done|error', + pp_for_n INT NOT NULL DEFAULT 0 COMMENT '이 후처리가 반영한 n_complete', + pp_lock_until DATETIME NULL COMMENT '락 만료 (죽은 워커 복구)', + pp_lock_owner VARCHAR(64) NULL COMMENT '잠근 호스트/pid', + pp_error VARCHAR(500) NULL COMMENT '후처리 실패 메시지', + leftover_note VARCHAR(200) NULL COMMENT '웹 남은횟수 안내 캐시', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '행 갱신', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '최초 생성', + PRIMARY KEY (study_name), + KEY idx_pp_needed (pp_needed, pp_status), + KEY idx_job (job_id), + KEY idx_updated (updated_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='Optuna 스터디별 웹 결과·후처리 산출 (파일 JSON 대체)'; +``` + +
+ ### 📡 실시간 마켓 데이터 (WebSocket / Market Data) #### `ws_candles` @@ -3367,7 +3436,8 @@ CREATE TABLE `ws_candles` ( | `tick_seq` | `bigint(20)` | NULL | NULL | 국장틱: 틱 일련번호 | | `session` | `varchar(8)` | NULL | NULL | 국장틱: 세션 태그 | | `currency` | `varchar(8)` | NOT NULL | KRW | 국장틱: 통화 (KRW/USD) | -| `source` | `varchar(16)` | NOT NULL | kis | 국장틱: 데이터 소스 (kis/kiwoom/ls 등) | +| `source` | `varchar(16)` | NOT NULL | kis | 국장틱: 증권사 (kis/kiwoom/ls). REST 현재가 아님 | +| `channel` | `varchar(10)` | NOT NULL | ws | 국장틱: 경로 (ws\|rest). 체결 틱은 ws만 적재. ka10007 INSERT 금지 | | `recv_ts` | `varchar(30)` | NOT NULL | | 국장틱: 수신 시각 | **인덱스**: @@ -3390,6 +3460,7 @@ CREATE TABLE `ws_ticks` ( `session` varchar(8) DEFAULT NULL, `currency` varchar(8) NOT NULL DEFAULT 'KRW', `source` varchar(16) NOT NULL DEFAULT 'kis', + `channel` varchar(10) NOT NULL DEFAULT 'ws', `recv_ts` varchar(30) NOT NULL, PRIMARY KEY (`id`), KEY `idx_ws_ticks_lookup` (`market`,`code`,`tick_time`), @@ -3415,7 +3486,8 @@ CREATE TABLE `ws_ticks` ( | `tick_seq` | `bigint(20)` | NULL | NULL | 해외틱: 틱 일련번호 | | `session` | `varchar(8)` | NULL | NULL | 해외틱: 세션 태그 | | `currency` | `varchar(8)` | NOT NULL | USD | 해외틱: 통화 (KRW/USD) | -| `source` | `varchar(16)` | NOT NULL | kis_us | 해외틱: 데이터 소스 (kis/kiwoom/ls 등) | +| `source` | `varchar(16)` | NOT NULL | kis_us | 해외틱: 증권사 (kis_us 등). REST 현재가 아님 | +| `channel` | `varchar(10)` | NOT NULL | ws | 해외틱: 경로 (ws\|rest). 체결 틱은 ws만 적재 | | `recv_ts` | `varchar(30)` | NOT NULL | | 해외틱: 수신 시각 | **인덱스**: @@ -3438,6 +3510,7 @@ CREATE TABLE `ws_ticks_us` ( `session` varchar(8) DEFAULT NULL, `currency` varchar(8) NOT NULL DEFAULT 'USD', `source` varchar(16) NOT NULL DEFAULT 'kis_us', + `channel` varchar(10) NOT NULL DEFAULT 'ws', `recv_ts` varchar(30) NOT NULL, PRIMARY KEY (`id`), KEY `idx_ws_ticks_us_lookup` (`market`,`code`,`tick_time`), diff --git a/docs/옵투나.md b/docs/옵투나.md index 758a17e..a2e596b 100644 --- a/docs/옵투나.md +++ b/docs/옵투나.md @@ -32,6 +32,10 @@ python3 -u kis_trader/backtest/optuna_rerun_postprocess.py \ 웹 Optuna 버튼도 완료 시 후처리를 붙인다 (`optuna_postprocess_topn`). `orderbook_recommend` 블록 + `postprocess_topn` 이 JSON에 실림. +**후처리 자동 실행 게이트** (`kis_study_trials` / `--study-trials`): +끝난 기준 = **시도 수** `COMPLETE + PRUNED + FAIL` ≥ 목표. +`tp_max < tp` 같은 pruned(문제 출제 오류)도 시도로 친다. COMPLETE만 세서 스킵하지 않음 (2026-08-21). + --- ## 2. 진입 후처리 격자 (env_config_ext) diff --git a/kis_trader/backtest/breakout_backtest_common.py b/kis_trader/backtest/breakout_backtest_common.py index 6ddee31..329dfcb 100644 --- a/kis_trader/backtest/breakout_backtest_common.py +++ b/kis_trader/backtest/breakout_backtest_common.py @@ -379,23 +379,17 @@ def load_breakout_candles_by_code( db, start_key, end_key, min_bars=min_bars, ) - from kis_trader.backtest.bt_candle_source import ( - fetch_ws_candles_for_code, - list_ws_candle_codes, - ) + from kis_trader.backtest.bt_candle_source import fetch_ws_candles_by_code_bulk ind_cols = ws_candles_select_indicator_cols(db) - codes = list_ws_candle_codes(db, 1, start_key, end_key) - + loaded = fetch_ws_candles_by_code_bulk( + db, 1, start_key, end_key, + extra_select=ind_cols, + confirmed_only=True, + ) candles_by_code: Dict[str, List[Dict]] = {} total_candles = 0 - - for code in codes: - rows = fetch_ws_candles_for_code( - db, code, 1, start_key, end_key, - extra_select=ind_cols, - confirmed_only=True, - ) + for code, rows in loaded.items(): if len(rows) < min_bars: continue candles_by_code[code] = rows diff --git a/kis_trader/backtest/breakout_tick_loader.py b/kis_trader/backtest/breakout_tick_loader.py index a6ad3f1..0aa3293 100644 --- a/kis_trader/backtest/breakout_tick_loader.py +++ b/kis_trader/backtest/breakout_tick_loader.py @@ -297,8 +297,23 @@ def load_breakout_ticks_by_code( "📌 틱 시간축 폴백: raw=%d → kept=%d (같은 초 메인 우선, 메인 lag>2s면 보조)", total, kept, ) + raw_before_merge = total total = kept + # 옵투나/백테: 틱 벤더(kis|kiwoom|ls) 비율 — 실매 트리거 피드 추적과 동일 축 + try: + from kis_trader.backtest.optuna_feed_trace import log_bt_tick_feed_trace + + log_bt_tick_feed_trace( + dict(out), + table=table, + main_src=main_src, + raw_total=raw_before_merge, + context="틱로드", + ) + except Exception as e: + logger.debug("틱 피드추적 로그 스킵: %s", e) + return dict(out), total diff --git a/kis_trader/backtest/bt_candle_source.py b/kis_trader/backtest/bt_candle_source.py index 7e5067f..54d8987 100644 --- a/kis_trader/backtest/bt_candle_source.py +++ b/kis_trader/backtest/bt_candle_source.py @@ -7,6 +7,9 @@ CANDLE_SOURCE=kis|kiwoom 이면 그 메인(+구멍). 빈값이면 LIVE_TICK_PROV """ from __future__ import annotations +import logging +import time +from collections import defaultdict from typing import Any, Dict, List, Optional, Sequence, Tuple from kis_trader.ws.candle_series import ( @@ -18,6 +21,8 @@ from kis_trader.ws.candle_series import ( ReadPair = Tuple[str, str] +logger = logging.getLogger("bt_candle_source") + # 레거시 호환 이름 (실제 필터는 live_read_pairs) BT_WS_CANDLE_SOURCES: Tuple[str, ...] = ("kiwoom", "kis", "rest", "rollup_1m") @@ -75,28 +80,116 @@ def _pairs_in_sql(pairs: Sequence[ReadPair]) -> Tuple[str, List[str]]: return " AND (" + " OR ".join(parts) + ")", params -def _ticks_for_bar_garbage(db, code: str, start_key: str, end_key: str) -> Optional[List[Dict[str, Any]]]: - from kis_trader.engine.feed_fallback import candle_garbage_fallback_enabled - - if not candle_garbage_fallback_enabled(): - return None +def _tick_time_bounds(start_key: str, end_key: str) -> Tuple[str, str]: sk = str(start_key or "").strip() ek = str(end_key or "").strip() if len(sk) == 12: sk = sk + "00" if len(ek) == 12: ek = ek + "59" + return sk, ek + + +def _ws_ticks_table(market: Optional[str]) -> str: + mk = (market or "").strip().upper() + if mk == "US": + return "ws_ticks_us" + return "ws_ticks" + + +def _ticks_for_bar_garbage( + db, + code: str, + start_key: str, + end_key: str, + *, + market: Optional[str] = None, +) -> Optional[List[Dict[str, Any]]]: + from kis_trader.engine.feed_fallback import candle_garbage_fallback_enabled + + if not candle_garbage_fallback_enabled(): + return None + sk, ek = _tick_time_bounds(start_key, end_key) + mk = (market or "").strip().upper() + table = _ws_ticks_table(mk if mk in ("US", "KR") else "KR") try: - rows = db.conn.execute( - "SELECT tick_time, source, tick_time_raw FROM ws_ticks " - "WHERE code=%s AND tick_time >= %s AND tick_time <= %s", - (str(code).strip(), sk, ek), - ).fetchall() + if mk in ("US", "KR"): + rows = db.conn.execute( + f"SELECT tick_time, source, tick_time_raw FROM {table} " + "WHERE market=%s AND code=%s AND tick_time >= %s AND tick_time <= %s", + (mk, str(code).strip(), sk, ek), + ).fetchall() + else: + rows = db.conn.execute( + f"SELECT tick_time, source, tick_time_raw FROM {table} " + "WHERE market=%s AND code=%s AND tick_time >= %s AND tick_time <= %s", + ("KR", str(code).strip(), sk, ek), + ).fetchall() return [dict(r) for r in (rows or [])] except Exception: return None +def _load_ticks_by_code_bulk( + db, + start_key: str, + end_key: str, + *, + market: Optional[str] = None, + codes_filter: Optional[Sequence[str]] = None, +) -> Optional[Dict[str, List[Dict[str, Any]]]]: + """쓰레기 검사용 틱 — 기간 1~2쿼리. OFF 면 None (봉만 dedupe).""" + from kis_trader.engine.feed_fallback import candle_garbage_fallback_enabled + + if not candle_garbage_fallback_enabled(): + return None + sk, ek = _tick_time_bounds(start_key, end_key) + mk = (market or "").strip().upper() + out: Dict[str, List[Dict[str, Any]]] = defaultdict(list) + want: List[str] = [] + if codes_filter: + want = [str(c).strip() for c in codes_filter if str(c).strip()] + in_sql = "" + in_params: List[str] = [] + if want: + in_sql = " AND code IN (" + ",".join(["%s"] * len(want)) + ")" + in_params = want + + def _pull(table: str, mkt: str) -> int: + rows = db.conn.execute( + f"SELECT code, tick_time, source, tick_time_raw FROM {table} " + "WHERE market=%s AND tick_time >= %s AND tick_time <= %s" + + in_sql, + (mkt, sk, ek, *in_params), + ).fetchall() + n = 0 + for r in rows or []: + code = str(r.get("code") or "").strip() + if not code: + continue + out[code].append(dict(r)) + n += 1 + return n + + try: + n_kr = n_us = 0 + if mk == "US": + n_us = _pull("ws_ticks_us", "US") + elif mk == "KR": + n_kr = _pull("ws_ticks", "KR") + else: + n_kr = _pull("ws_ticks", "KR") + try: + n_us = _pull("ws_ticks_us", "US") + except Exception: + n_us = 0 + logger.info("📥 틱 bulk 쓰레기검사용: KR=%s US=%s 종목=%s", n_kr, n_us, len(out)) + return dict(out) + except Exception as exc: + logger.warning("틱 bulk 스킵(봉만): %s", exc) + return None + + def list_ws_candle_codes( db, timeframe: int, @@ -169,13 +262,136 @@ def fetch_ws_candles_for_code( + " ORDER BY candle_time ASC", [int(timeframe), code, start_key, end_key, *src_params], ).fetchall() - ticks = _ticks_for_bar_garbage(db, str(code), start_key, end_key) + ticks = _ticks_for_bar_garbage( + db, str(code), start_key, end_key, market=mk or None, + ) return dedupe_by_read_pairs( [dict(r) for r in rows], pairs, ticks=ticks, tf_min=int(timeframe), missing_policy="hole", live_cover=False, ) +def fetch_ws_candles_by_code_bulk( + db, + timeframe: int, + start_key: str, + end_key: str, + *, + extra_select: str = "", + peak_sel: str = "", + market: Optional[str] = None, + confirmed_only: bool = True, + codes_filter: Optional[Sequence[str]] = None, +) -> Dict[str, List[Dict[str, Any]]]: + """기간 전체 봉 1쿼리 + 틱 1~2쿼리 후 종목별 기존 쓰레기 검사. + + 웜업용 ``fetch_ws_candles_for_code`` / ``fetch_ws_candles_warmup_before`` 는 그대로. + """ + t0 = time.perf_counter() + pairs = resolve_bt_read_pairs() + confirmed_sql = " AND is_confirmed=1" if confirmed_only else "" + mk = (market or "").strip().upper() + src_sql, src_params = _pairs_in_sql(pairs) + cols = ( + f"code, candle_time, open, high, low, close, volume, source, channel" + f"{peak_sel}{extra_select}" + ) + want: List[str] = [] + if codes_filter: + want = [ + str(c).strip() + for c in codes_filter + if str(c).strip() + ] + in_sql = "" + in_params: List[str] = [] + if want: + in_sql = " AND code IN (" + ",".join(["%s"] * len(want)) + ")" + in_params = want + + params: List[Any] = [int(timeframe)] + mk_sql = "" + if mk: + mk_sql = " AND market=%s" + params.append(mk) + params.extend([start_key, end_key, *src_params, *in_params]) + + rows = db.conn.execute( + f"SELECT {cols} FROM ws_candles " + "WHERE timeframe=%s" + + mk_sql + + " AND candle_time >= %s AND candle_time <= %s" + + confirmed_sql + + src_sql + + in_sql + + " ORDER BY code ASC, candle_time ASC", + params, + ).fetchall() + + by_code: Dict[str, List[Dict[str, Any]]] = defaultdict(list) + for r in rows or []: + code = str(r.get("code") or "").strip() + if not code: + continue + by_code[code].append(dict(r)) + + ticks_map = _load_ticks_by_code_bulk( + db, start_key, end_key, market=mk or None, codes_filter=want or None, + ) + out: Dict[str, List[Dict[str, Any]]] = {} + stats: Dict[str, Any] = { + "slots": 0, "picked": 0, "hole": 0, "garbage_skip": 0, + "pick_by": {}, "garbage_by": {}, "raw_by": {}, + } + for code, raw in by_code.items(): + ticks = None if ticks_map is None else ticks_map.get(code, []) + out[code] = dedupe_by_read_pairs( + raw, pairs, ticks=ticks, tf_min=int(timeframe), + missing_policy="hole", live_cover=False, stats=stats, + ) + elapsed = time.perf_counter() - t0 + n_tick_rows = 0 + if ticks_map: + n_tick_rows = sum(len(v) for v in ticks_map.values()) + slots = int(stats.get("slots") or 0) or 1 + pick_by = stats.get("pick_by") or {} + garb_by = stats.get("garbage_by") or {} + raw_by = stats.get("raw_by") or {} + pick_txt = " ".join( + "%s=%s(%.0f%%)" % (k, v, 100.0 * int(v) / max(1, int(stats.get("picked") or 1))) + for k, v in sorted(pick_by.items(), key=lambda x: -int(x[1])) + ) or "—" + garb_txt = " ".join( + "%s=%s" % (k, v) for k, v in sorted(garb_by.items(), key=lambda x: -int(x[1])) + ) or "0" + raw_txt = " ".join( + "%s=%s" % (k, v) for k, v in sorted(raw_by.items(), key=lambda x: -int(x[1])) + ) or "—" + logger.info( + "📂 캔들 bulk: 봉 %s행 · 틱 %s행 · 종목 %s · %.2fs (tf=%s market=%s)", + len(rows or []), + n_tick_rows, + len(out), + elapsed, + int(timeframe), + mk or "ALL", + ) + logger.info( + "📊 봉 pick 비율(1차→2차→REST): 슬롯 %s · pick %s · hole %s(%.1f%%) · " + "쓰레기스킵 %s(슬롯대비 %.1f%%) | raw[%s] | pick[%s] | garbage[%s]", + slots, + stats.get("picked"), + stats.get("hole"), + 100.0 * int(stats.get("hole") or 0) / slots, + stats.get("garbage_skip"), + 100.0 * int(stats.get("garbage_skip") or 0) / slots, + raw_txt, + pick_txt, + garb_txt, + ) + return out + + def fetch_ws_candles_warmup_before( db, code: str, diff --git a/kis_trader/backtest/momentum_backtest_common.py b/kis_trader/backtest/momentum_backtest_common.py index 774e3f5..379277f 100644 --- a/kis_trader/backtest/momentum_backtest_common.py +++ b/kis_trader/backtest/momentum_backtest_common.py @@ -402,24 +402,20 @@ def load_momentum_candles_by_code( """ market: None/빈값 = 전체(기존 동작), 'US'|'KR' = ws_candles.market 필터. """ - from kis_trader.backtest.bt_candle_source import ( - fetch_ws_candles_for_code, - list_ws_candle_codes, - ) + from kis_trader.backtest.bt_candle_source import fetch_ws_candles_by_code_bulk period_start = str(start_key)[:12] mk = (market or "").strip().upper() - codes = list_ws_candle_codes(db, 1, start_key, end_key, market=mk or None) ind_cols = ws_candles_select_indicator_cols(db) + loaded = fetch_ws_candles_by_code_bulk( + db, 1, start_key, end_key, + extra_select=ind_cols, + market=mk or None, + confirmed_only=False, + ) candles_by_code: Dict[str, List[Dict]] = {} total = 0 - for code in codes: - rows = fetch_ws_candles_for_code( - db, code, 1, start_key, end_key, - extra_select=ind_cols, - market=mk or None, - confirmed_only=False, - ) + for code, rows in loaded.items(): if len(rows) < 6: continue candles_by_code[code] = rows diff --git a/kis_trader/backtest/momentum_portfolio_backtest.py b/kis_trader/backtest/momentum_portfolio_backtest.py index f45500c..c819ce9 100644 --- a/kis_trader/backtest/momentum_portfolio_backtest.py +++ b/kis_trader/backtest/momentum_portfolio_backtest.py @@ -593,6 +593,19 @@ def _collect_buy_candidates( ticks_by_code, code, entry_bar_time, entry_open, params, min_tick_time=min_tick_time, ) + # 옵투나 원인파악: 통과 후보 샘플에 틱벤더·호가·틱타임 꼬리 (상한 BT_FEED_DETAIL_LOG_MAX) + try: + from kis_trader.backtest.optuna_feed_trace import maybe_log_bt_trigger_sample + + maybe_log_bt_trigger_sample( + code, eval_params, + entry_price=float(entry_price or 0), + entry_time=str(entry_time_key or ""), + entry_src=str(entry_src or ""), + context="MOMENTUM-TRIGGER", + ) + except Exception: + pass pe_data: Dict[str, Any] = { "entry_time": entry_time_key, "entry_price": entry_price, diff --git a/kis_trader/backtest/optuna_breakout.py b/kis_trader/backtest/optuna_breakout.py index 221afe0..59dd768 100644 --- a/kis_trader/backtest/optuna_breakout.py +++ b/kis_trader/backtest/optuna_breakout.py @@ -169,6 +169,12 @@ def prepare_breakout_search_context( _hs = resolve_backtest_universe_history_source(history_source) base_fixed["_universe_history_source"] = _hs + try: + from kis_trader.backtest.optuna_feed_trace import log_bt_feed_chain_banner + + log_bt_feed_chain_banner(context="Optuna-BREAKOUT") + except Exception: + pass codes_candles = _load_candles_for_search( start, end, base_fixed.get("lookback_min", 1), base_fixed, history_source=_hs, @@ -348,6 +354,8 @@ def run_breakout_optuna( direction="maximize", sampler=_make_sampler(sampler_name, seed), ) + from kis_trader.backtest.optuna_study_store import bind_study_trials, finalize_optuna_export + bind_study_trials(study, n_trials=n_trials, log=logger) def objective(trial: optuna.Trial) -> float: if ctx.mode == "tpe": @@ -491,17 +499,28 @@ def run_breakout_optuna( out_path, strategy="breakout", mode=ctx.mode, note="mode_combo params 저장(실측 전)", log=logger, ) - enrich_out_data_with_mode_combo( - out_data, - evaluate_fn=_eval_mode, - grid_keys=ctx.grid_keys, + def _enrich() -> None: + enrich_out_data_with_mode_combo( + out_data, + evaluate_fn=_eval_mode, + grid_keys=ctx.grid_keys, + log=logger, + on_partial_save=_save_partial, + ) + with open(out_path, "w", encoding="utf-8") as f: + json.dump(out_data, f, indent=2, ensure_ascii=False) + announce_optuna_json_path( + out_path, strategy="breakout", mode=ctx.mode, note="최종 JSON", log=logger, + ) + + finalize_optuna_export( + study, + out_data=out_data, + out_path=out_path, + strategy="breakout", + mode=ctx.mode, + enrich_fn=_enrich, log=logger, - on_partial_save=_save_partial, - ) - with open(out_path, "w", encoding="utf-8") as f: - json.dump(out_data, f, indent=2, ensure_ascii=False) - announce_optuna_json_path( - out_path, strategy="breakout", mode=ctx.mode, note="최종 JSON", log=logger, ) study._kis_export_path = out_path # type: ignore[attr-defined] return study diff --git a/kis_trader/backtest/optuna_common.py b/kis_trader/backtest/optuna_common.py index 6b7246a..1ccac93 100644 --- a/kis_trader/backtest/optuna_common.py +++ b/kis_trader/backtest/optuna_common.py @@ -314,6 +314,73 @@ def stability_fields_from_trial_attrs(trial: Any) -> Dict[str, Any]: } +def build_results_stable_tier( + rows: List[Dict[str, Any]], + *, + top_n: int = 10, +) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]: + """ + 안정 Top — 사후합격(gated)·플러스 PnL 과 독립 (마이너스도 상대 순위). + + 1) 일별 안정 게이트 통과분 → 안정점수순 + 2) 0건이면 학습풀 전체를 안정점수순 TopN (게이트는 참고·폴백 표시) + → 3일 전패장에서 max_losing_days=1 이면 게이트 0이어도 표가 비지 않음 + """ + max_lose, min_worst, lam, min_days = optuna_stable_gate_defaults() + n = max(1, int(top_n or 10)) + all_rows = [r for r in (rows or []) if isinstance(r, dict)] + pool = [r for r in all_rows if row_passes_stable_gates(r)] + fallback = False + if pool: + stable = _sort_optuna_rows(pool, "stability")[:n] + else: + fallback = True + with_stab = [ + r for r in all_rows + if r.get("stability_score") is not None + or r.get("n_active_days") is not None + or r.get("daily_pnl") is not None + ] + src = with_stab if with_stab else all_rows + stable = _sort_optuna_rows(src, "stability")[:n] + meta = { + "max_losing_days": max_lose, + "min_worst_day_pnl": min_worst, + "stability_lambda": lam, + "min_active_days": min_days, + "score_note": "stability_score = mean(일PnL) − λ × std(일PnL)", + "fallback_rank_only": bool(fallback), + "n_gate_pass": len(pool), + "fallback_note": ( + "안정 게이트 0건 → 학습풀 안정점수순 TopN (마이너스 PnL 포함 · 상대비교)" + if fallback else "" + ), + } + return stable, meta + + +def resolve_results_stable( + data: Optional[Dict[str, Any]], + *, + top_n: Optional[int] = None, +) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]: + """JSON results_stable 우선 · 비면 학습풀에서 즉시 재구성 (구잡·gated=0 공용).""" + 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_stable") or []) if isinstance(r, dict)] + gates = dict(data.get("stable_gates") or {}) + if stored: + return stored[:n], gates + allr = list(data.get("results_all") or data.get("results") or []) + stable, meta = build_results_stable_tier(allr, top_n=n) + gates.update(meta) + return stable, gates + + def build_optuna_result_tiers( rows: List[Dict[str, Any]], *, @@ -325,11 +392,10 @@ def build_optuna_result_tiers( - results_all: 완료·게이트통과(탐색게이트) trial 전부 정렬 - results: 하위호환 — 플러스 PnL 우선(없으면 all) - - results_gated: 승률·PF 사후 필터 (apply 후보) - - results_stable: gated ∩ 일별 안정성 게이트 (들쭉날쭉 완화 후보) + - results_gated: 승률·PF 사후 필터 (apply 후보, PnL>0) + - results_stable: 학습풀 일별 안정성 (gated·플러스 독립 · 게이트0이면 점수순 폴백) """ rep_wr, rep_pf, rep_tr = optuna_report_gate_defaults() - max_lose, min_worst, lam, min_days = optuna_stable_gate_defaults() all_sorted = _sort_optuna_rows(rows, sort_by) profitable = [r for r in all_sorted if float(r.get("total_pnl") or 0) > 0] learning = profitable if profitable else all_sorted @@ -340,29 +406,27 @@ def build_optuna_result_tiers( ) and float(r.get("total_pnl") or 0) > 0 ] - stable_pool = [r for r in gated if row_passes_stable_gates(r)] - stable = _sort_optuna_rows(stable_pool, "stability") + # 안정 TopN 표용 — 전체 풀에서 상위 (gated 잘림·플러스와 무관) + try: + stable_ui_n = max(1, int(get_env_int("OPTUNA_POST_TOP_N", 10))) + except Exception: + stable_ui_n = 10 + stable, stable_meta = build_results_stable_tier(all_sorted, top_n=stable_ui_n) return { "results_all": all_sorted[:top_n], "results": learning[:top_n], "results_gated": gated[:top_n], - "results_stable": stable[:top_n], + "results_stable": stable, "report_gates": { "min_win_rate": rep_wr, "min_pf": rep_pf, "min_trades": rep_tr, }, - "stable_gates": { - "max_losing_days": max_lose, - "min_worst_day_pnl": min_worst, - "stability_lambda": lam, - "min_active_days": min_days, - "score_note": "stability_score = mean(일PnL) − λ × std(일PnL)", - }, + "stable_gates": stable_meta, "search_gates_note": ( "탐색 min_win_rate/min_pf 기본 0 — TPE가 PnL 차이를 학습. " "적용·운영 후보는 results_gated(report_gates). " - "들쭉날쭉 완화 후보는 results_stable(stable_gates)." + "들쭉날쭉 완화·상대비교는 results_stable (gated/플러스 독립 · 게이트0이면 점수순 폴백)." ), "n_results_all": len(all_sorted), "n_results_learning": len(learning), @@ -843,7 +907,7 @@ def ensure_optuna_gate_env_defaults(db: Any = None) -> None: "OPTUNA_DAILY_TRAIL_ARM_STEP": "5000", "OPTUNA_DAILY_TRAIL_MIN_ARM": "10000", "OPTUNA_DAILY_TRAIL_TIER_DROPS": "40,30,20", - "OPTUNA_POST_TOP_N": "5", + "OPTUNA_POST_TOP_N": "10", "OPTUNA_POST_INCLUDE_MODE": "true", "OPTUNA_POST_INCLUDE_LIVE": "true", "OPTUNA_POST_INCLUDE_STABLE": "true", diff --git a/kis_trader/backtest/optuna_feed_trace.py b/kis_trader/backtest/optuna_feed_trace.py new file mode 100644 index 0000000..c1dc0df --- /dev/null +++ b/kis_trader/backtest/optuna_feed_trace.py @@ -0,0 +1,369 @@ +""" +옵투나·백테 피드 추적 로그 — 실매 TRIGGER_FEED_DETAIL 과 같은 축. + +무엇을 보나: + - 틱/호가 DB 로드 후 벤더(kis|kiwoom|ls|…) 건수·비율 + - 설정 1·2·3차 체인 (실매 live_*_primary 와 동일) + - (옵션) 매수 후보 샘플: px·틱타임·호가 bid/ask·entry_source + +실매 `_scan_log` 꼬리와 문구를 맞춰, 옵투나 로그만 봐도 “어느 벤더로 체결·호가판정했는지” 추적 가능. +""" +from __future__ import annotations + +from collections import Counter +from typing import Any, Dict, List, Optional, Tuple + +from kis_trader.utils.env import get_env_bool, get_env_int +from kis_trader.utils.logger import get_logger + +logger = get_logger("kis_trader.optuna_feed_trace") + +# 프로세스당 샘플 상한 (옵투나 trial×종목 폭주 방지) +_sample_logged = 0 +_pp_sample_logged = 0 + + +def bt_feed_detail_enabled() -> bool: + """옵투나 TRIGGER 샘플 ON/OFF (벤더 비율 INFO는 항상).""" + return get_env_bool("BT_FEED_DETAIL_LOG", True) + + +def reset_bt_feed_sample_counter(*, postprocess: bool = False) -> None: + """후처리 시작 시 샘플 카운터 리셋 — 코어 TPE에서 소진돼도 호가방 로그가 나오게.""" + global _sample_logged, _pp_sample_logged + if postprocess: + _pp_sample_logged = 0 + else: + _sample_logged = 0 + + +def log_bt_postprocess_ob_db_scope( + db: Any, + *, + table: str, + cols: Optional[List[str]] = None, + source_filter: Optional[tuple] = None, + date_from: str = "", + date_to: str = "", + context: str = "호가후처리", +) -> None: + """후처리 호가 추천 직전 — 테이블·기간·source 건수 (코어 TPE 호가OFF 와 무관).""" + try: + from kis_trader.engine.feed_fallback import live_ob_primary + + primary = live_ob_primary() + except Exception: + primary = "?" + sf = tuple(source_filter or ()) + logger.info( + "🔎 [%s] table=%s | 1차설정=%s | source_filter=%s | 기간=%s~%s", + context, table, primary, sf or "(all)", + (date_from or "?")[:10], (date_to or "?")[:10], + ) + colset = set(cols or []) + if not db or "source" not in colset: + if db and "source" not in colset: + logger.info("🔎 [%s] source 컬럼 없음 — 벤더 비율 집계 스킵", context) + return + try: + where: List[str] = [] + params: List[Any] = [] + df = str(date_from or "").replace("-", "").strip()[:8] + dt = str(date_to or "").replace("-", "").strip()[:8] + if len(df) == 8: + where.append("snap_time >= %s") + params.append(df + "000000") + if len(dt) == 8: + where.append("snap_time <= %s") + params.append(dt + "235959") + if sf: + ph = ",".join(["%s"] * len(sf)) + where.append(f"source IN ({ph})") + params.extend(sf) + wh = (" WHERE " + " AND ".join(where)) if where else "" + sql = f"SELECT source, COUNT(*) AS n FROM {table}{wh} GROUP BY source ORDER BY n DESC" + rows = db.conn.execute(sql, tuple(params)).fetchall() + ctr: Counter = Counter() + for r in rows or []: + k = str((r.get("source") if isinstance(r, dict) else r[0]) or "?").strip().lower() or "?" + n = int((r.get("n") if isinstance(r, dict) else r[1]) or 0) + ctr[k] += n + logger.info( + "🔎 [%s DB벤더] %s", + context, _fmt_counter(ctr) if ctr else "(0건 — 기간/필터 미스)", + ) + except Exception as e: + logger.warning("⚠️ [%s] DB 벤더 집계 실패: %s", context, e) + + +def maybe_log_bt_ob_postprocess_sample( + *, + code: str, + buy_time: str, + buy_price: float, + snap_t: str = "", + best_bid: int = 0, + best_ask: int = 0, + spread_pct: float = 0.0, + bid_ask_ratio: float = 0.0, + snap_source: str = "", + hit: bool = True, + context: str = "호가후처리샘플", +) -> None: + """후처리: 체결↔호가 스냅 매칭 샘플 (상한 BT_FEED_DETAIL_LOG_MAX).""" + global _pp_sample_logged + if not bt_feed_detail_enabled(): + return + max_n = max(0, get_env_int("BT_FEED_DETAIL_LOG_MAX", 40)) + if max_n <= 0 or _pp_sample_logged >= max_n: + return + try: + from kis_trader.engine.feed_fallback import live_ob_primary + + op = live_ob_primary() + alt = "kiwoom" if op == "kis" else "kis" + src = str(snap_source or "").strip().lower() or ("없음" if not hit else "?") + if hit: + logger.info( + "🔎 [%s] %s buy=%s px=%s | 호가1차설정=%s(체인 %s→%s→ls) | " + "호가실제 src=%s snap=%s bid=%s ask=%s spr=%.3f%% or=%.3f", + context, code, buy_time, int(buy_price or 0), + op, op, alt, + src, snap_t or "-", + int(best_bid or 0), int(best_ask or 0), + float(spread_pct or 0), float(bid_ask_ratio or 0), + ) + else: + logger.info( + "🔎 [%s] %s buy=%s px=%s | 호가1차설정=%s | 호가실제=없음(스냅 미스)", + context, code, buy_time, int(buy_price or 0), op, + ) + _pp_sample_logged += 1 + except Exception as e: + logger.debug("호가후처리샘플 스킵: %s", e) + + +def log_bt_feed_chain_banner(*, context: str = "Optuna/백테") -> None: + """로드 직전 — 설정상 1·2·3차 체인만 (실매 format_trigger_feed_trace 머리와 동일).""" + try: + from kis_trader.engine.feed_fallback import live_ob_primary, live_tick_primary + + tp = live_tick_primary() + op = live_ob_primary() + alt_t = "kiwoom" if tp == "kis" else "kis" + alt_o = "kiwoom" if op == "kis" else "kis" + tick_src_env = "" + try: + import os + + tick_src_env = str(os.environ.get("TICK_SOURCE") or "").strip().lower() + except Exception: + pass + extra = f" | TICK_SOURCE강제={tick_src_env}" if tick_src_env else "" + logger.info( + "🔎 [%s 피드체인] 틱1차=%s(체인 %s→%s→ls) | 호가1차=%s(체인 %s→%s→ls)%s", + context, tp, tp, alt_t, op, op, alt_o, extra, + ) + except Exception as e: + logger.debug("피드체인 배너 스킵: %s", e) + + +def _pct(n: int, total: int) -> float: + return (100.0 * n / total) if total > 0 else 0.0 + + +def _fmt_counter(ctr: Counter, *, total: Optional[int] = None) -> str: + tot = int(total if total is not None else sum(ctr.values())) + if tot <= 0: + return "(0건)" + parts = [ + f"{k or '?'}={v:,}({_pct(v, tot):.1f}%)" + for k, v in ctr.most_common() + ] + return f"total={tot:,} | " + " ".join(parts) + + +def count_tick_sources( + ticks_by_code: Optional[Dict[str, Any]], +) -> Counter: + """ticks_by_code {code: {minute: [tick|TickColumnView]}} → source Counter.""" + ctr: Counter = Counter() + if not ticks_by_code: + return ctr + try: + from kis_trader.backtest.shared_ticks import TickColumnView + except Exception: + TickColumnView = () # type: ignore + + for _code, minutes in ticks_by_code.items(): + if not isinstance(minutes, dict): + continue + for _mk, ticks in minutes.items(): + if TickColumnView and isinstance(ticks, TickColumnView): + owner = ticks.owner + src_arr = getattr(owner, "_source", None) + if src_arr is None: + ctr["?"] += len(list(ticks.iter_idx())) + continue + for i in ticks.iter_idx(): + try: + s = src_arr[i].decode("utf-8").strip().lower() or "?" + except Exception: + s = "?" + ctr[s] += 1 + continue + for t in (ticks or []): + if not isinstance(t, dict): + ctr["?"] += 1 + continue + s = str(t.get("source") or "").strip().lower() or "?" + ctr[s] += 1 + return ctr + + +def count_orderbook_sources( + orderbook_by_code: Optional[Dict[str, Any]], +) -> Counter: + """orderbook_by_code {code: {minute: [OrderbookSnapshot|dict]}}.""" + ctr: Counter = Counter() + if not orderbook_by_code: + return ctr + for _code, minutes in orderbook_by_code.items(): + if not isinstance(minutes, dict): + continue + for _mk, snaps in minutes.items(): + for snap in (snaps or []): + try: + s = str(getattr(snap, "source", None) or "").strip().lower() + if not s and isinstance(snap, dict): + s = str(snap.get("source") or "").strip().lower() + ctr[s or "?"] += 1 + except Exception: + ctr["?"] += 1 + return ctr + + +def log_bt_tick_feed_trace( + ticks_by_code: Optional[Dict[str, Any]], + *, + table: str = "ws_ticks", + main_src: str = "", + raw_total: Optional[int] = None, + context: str = "틱로드", +) -> Counter: + """틱 로드·시간축 폴백 직후 — 벤더 비율 INFO.""" + ctr = count_tick_sources(ticks_by_code) + kept = sum(ctr.values()) + try: + from kis_trader.engine.feed_fallback import live_tick_primary + + primary = str(main_src or live_tick_primary()).strip().lower() or "?" + except Exception: + primary = str(main_src or "?").strip().lower() or "?" + raw_bit = "" + if raw_total is not None and int(raw_total) != kept: + raw_bit = f" | raw={int(raw_total):,}→kept={kept:,}" + logger.info( + "🔎 [%s] table=%s | 1차설정=%s | 벤더 %s%s", + context, table, primary, _fmt_counter(ctr, total=kept), raw_bit, + ) + return ctr + + +def log_bt_orderbook_feed_trace( + orderbook_by_code: Optional[Dict[str, Any]], + meta: Optional[Dict[str, Any]] = None, + *, + context: str = "호가로드", +) -> Counter: + """호가 스냅 로드 직후 — source(kiwoom_0d|ls_uh1|log_backfill|…) 비율.""" + ctr = count_orderbook_sources(orderbook_by_code) + meta = meta or {} + hist = str(meta.get("orderbook_history_source") or "").strip().lower() or "?" + rows = int(meta.get("ws_orderbook_rows_loaded") or sum(ctr.values()) or 0) + try: + from kis_trader.engine.feed_fallback import live_ob_primary + + primary = live_ob_primary() + except Exception: + primary = "?" + logger.info( + "🔎 [%s] history_source=%s | 1차설정=%s | rows=%s | 벤더 %s", + context, hist, primary, f"{rows:,}", _fmt_counter(ctr), + ) + return ctr + + +def maybe_log_bt_trigger_sample( + code: str, + eval_params: Optional[Dict[str, Any]], + *, + entry_price: float = 0.0, + entry_time: str = "", + entry_src: str = "", + reject: str = "", + context: str = "TRIGGER샘플", +) -> None: + """매수 평가 직후 샘플 몇 건 — 실매 format_trigger_feed_trace 꼬리와 동일 축.""" + global _sample_logged + if not bt_feed_detail_enabled(): + return + max_n = max(0, get_env_int("BT_FEED_DETAIL_LOG_MAX", 40)) + if max_n <= 0 or _sample_logged >= max_n: + return + p = eval_params or {} + try: + from kis_trader.engine.feed_fallback import ( + extract_ob_trace_fields, + format_trigger_feed_trace, + tick_feed_tier, + ) + + tick_rec: Dict[str, Any] = {} + if entry_price and entry_src: + vendor = "" + es = str(entry_src or "") + if ":" in es: + vendor = es.split(":", 1)[1].strip().lower() + tick_rec = { + "vendor": vendor or "ws_ticks", + "tier": 1, + "label": es or "ws_ticks", + "price": float(entry_price), + "tick_time": str(entry_time or "")[:14], + } + ob_snap = p.get("_backtest_orderbook_snapshot") + ob_rec: Dict[str, Any] = {} + if ob_snap is not None: + ob_rec = extract_ob_trace_fields(ob_snap) + raw_src = str( + ob_rec.get("ob_source") or getattr(ob_snap, "source", "") or "" + ).strip().lower() + # kiwoom_0d / ls_uh1 / kis → 체인 벤더 라벨 + vendor = "ls" if raw_src.startswith("ls") else ( + "kiwoom" if "kiwoom" in raw_src or raw_src == "log_backfill" else ( + "kis" if "kis" in raw_src else (raw_src or "ob") + ) + ) + ob_rec["vendor"] = vendor + ob_rec["tier"] = tick_feed_tier(vendor, "") + ob_rec["label"] = raw_src or vendor + tail = format_trigger_feed_trace(tick_rec, ob_rec) + rej = f" reject={reject}" if reject else "" + logger.info( + "🔎 [%s] %s entry_src=%s%s | %s", + context, code, entry_src or "-", rej, tail, + ) + _sample_logged += 1 + except Exception as e: + logger.debug("TRIGGER샘플 스킵: %s", e) + + +def tick_source_label(tick: Optional[Dict[str, Any]], fallback: str = "ws_ticks") -> str: + """진입 source 문자열 — ws_ticks:kis / ws_ticks:kiwoom / ohlc_open.""" + if not tick: + return fallback + src = str(tick.get("source") or "").strip().lower() + if src: + return f"ws_ticks:{src}" + return "ws_ticks" diff --git a/kis_trader/backtest/optuna_momentum.py b/kis_trader/backtest/optuna_momentum.py index 7a373b3..51fd4a0 100644 --- a/kis_trader/backtest/optuna_momentum.py +++ b/kis_trader/backtest/optuna_momentum.py @@ -205,6 +205,14 @@ def prepare_momentum_search_context( f"총한도 {total_budget_f:,.0f}원 | 매매 {format_session_hm(base_fixed)} | market={mk}" ) + # 실매과 동일 1·2·3차 체인 — 옵투나 로그에서도 벤더 추적 가능 + try: + from kis_trader.backtest.optuna_feed_trace import log_bt_feed_chain_banner + + log_bt_feed_chain_banner(context="Optuna-MOMENTUM") + except Exception: + pass + logger.info("📂 캔들 로드 중 %s~%s (이 동안 Top5는 비어 있음 · trial 0)", start, end) codes_candles = _load_candles_for_search( start, end, base_fixed.get("rsi_period", 3), market=mk if mk in ("US", "KR") else None, @@ -423,6 +431,8 @@ def run_momentum_optuna( direction="maximize", sampler=_make_sampler(sampler_name, seed), ) + from kis_trader.backtest.optuna_study_store import bind_study_trials, finalize_optuna_export + bind_study_trials(study, n_trials=n_trials, log=logger) def objective(trial: optuna.Trial) -> float: if ctx.mode == "tpe": @@ -597,17 +607,29 @@ def run_momentum_optuna( log=logger, ) - enrich_out_data_with_mode_combo( - out_data, - evaluate_fn=_eval_mode, - grid_keys=ctx.grid_keys, + def _enrich() -> None: + enrich_out_data_with_mode_combo( + out_data, + evaluate_fn=_eval_mode, + grid_keys=ctx.grid_keys, + log=logger, + on_partial_save=_save_partial, + ) + with open(out_path, "w", encoding="utf-8") as f: + json.dump(out_data, f, indent=2, ensure_ascii=False) + announce_optuna_json_path( + out_path, strategy=_ann_strat, mode=ctx.mode, note="최종 JSON", log=logger, + ) + + finalize_optuna_export( + study, + out_data=out_data, + out_path=out_path, + strategy=_ann_strat, + mode=ctx.mode, + enrich_fn=_enrich, log=logger, - on_partial_save=_save_partial, - ) - with open(out_path, "w", encoding="utf-8") as f: - json.dump(out_data, f, indent=2, ensure_ascii=False) - announce_optuna_json_path( - out_path, strategy=_ann_strat, mode=ctx.mode, note="최종 JSON", log=logger, + symbol=_sym, ) study._kis_export_path = out_path # type: ignore[attr-defined] return study diff --git a/kis_trader/backtest/optuna_orderbook_recommend.py b/kis_trader/backtest/optuna_orderbook_recommend.py index aa31f84..fe93fd0 100644 --- a/kis_trader/backtest/optuna_orderbook_recommend.py +++ b/kis_trader/backtest/optuna_orderbook_recommend.py @@ -30,6 +30,7 @@ class Snap: best_ask: int bid_qty_l3: int = 0 ask_qty_l3: int = 0 + source: str = "" # kiwoom_0d / ls_uh1 … 후처리 피드 추적용 @dataclass @@ -201,6 +202,8 @@ def _fetch_ob_snaps( sel.append("bid_qty_l3") if "ask_qty_l3" in cols: sel.append("ask_qty_l3") + if "source" in cols: + sel.append("source") snap_sql = ( f"SELECT {', '.join(sel)} FROM {table} " "WHERE code=%s AND snap_time >= %s AND snap_time < %s" @@ -230,6 +233,7 @@ def _fetch_ob_snaps( best_ask=int(sr["best_ask"] or 0), bid_qty_l3=int(sr["bid_qty_l3"] or 0) if "bid_qty_l3" in sr else 0, ask_qty_l3=int(sr["ask_qty_l3"] or 0) if "ask_qty_l3" in sr else 0, + source=str(sr.get("source") or "").strip().lower() if "source" in sr else "", ) ) return snaps @@ -286,6 +290,7 @@ def raw_fills_to_ob_trades( """백테 체결 dict(buy_time/buy_price/qty/pnl) → 호가 TradeInfo.""" lookback, horizon = _ob_lookback_horizon() out: List[TradeInfo] = [] + n_miss = 0 for b in raw_fills or []: if not isinstance(b, dict): continue @@ -312,8 +317,42 @@ def raw_fills_to_ob_trades( actual_profit_rate=actual_profit_rate, snaps=snaps, ) + # 후처리 피드 추적: 스냅 hit/miss + 벤더·bid/ask + try: + from kis_trader.backtest.optuna_feed_trace import maybe_log_bt_ob_postprocess_sample + + if ti is not None: + es = ti.entry_snaps[-1] if ti.entry_snaps else None + maybe_log_bt_ob_postprocess_sample( + code=code, + buy_time=buy_dt.strftime("%Y%m%d%H%M%S"), + buy_price=buy_price, + snap_t=es.t.strftime("%Y%m%d%H%M%S") if es else "", + best_bid=int(es.best_bid) if es else 0, + best_ask=int(es.best_ask) if es else 0, + spread_pct=float(ti.orig_spread_pct), + bid_ask_ratio=float(ti.orig_bid_ask_ratio), + snap_source=str(es.source or "") if es else "", + hit=True, + ) + else: + n_miss += 1 + maybe_log_bt_ob_postprocess_sample( + code=code, + buy_time=buy_dt.strftime("%Y%m%d%H%M%S"), + buy_price=buy_price, + hit=False, + ) + except Exception: + if ti is None: + n_miss += 1 if ti: out.append(ti) + if n_miss > 0: + logger.info( + "🔎 [호가후처리] 체결→스냅 미스 %s/%s건 (필터·기간·종목 공백)", + n_miss, len(raw_fills or []), + ) return out @@ -809,6 +848,28 @@ def recommend_orderbook_parameters( lg.warning("⚠️ [%s] 테이블 조회 실패: %s. 추천 생략.", table, exc) return {"ok": False, "reason": "table_not_found", "table": table} + # 코어 TPE 호가OFF와 무관 — 후처리에서 DB 호가 벤더·기간을 추적 로그 + try: + from kis_trader.backtest.optuna_feed_trace import ( + log_bt_feed_chain_banner, + log_bt_postprocess_ob_db_scope, + reset_bt_feed_sample_counter, + ) + + reset_bt_feed_sample_counter(postprocess=True) + log_bt_feed_chain_banner(context="호가후처리") + log_bt_postprocess_ob_db_scope( + db, + table=table, + cols=cols, + source_filter=source_filter, + date_from=str(date_from or ""), + date_to=str(date_to or ""), + context="호가후처리", + ) + except Exception as e: + lg.debug("호가후처리 피드추적 스킵: %s", e) + date_sql = f"SELECT DISTINCT SUBSTR(snap_time, 1, 8) as dt FROM {table}" date_params: Tuple[Any, ...] = () if source_filter and "source" in cols: @@ -886,8 +947,33 @@ def recommend_orderbook_parameters( trades.append(ti) if len(trades) < 3: - lg.warning("⚠️ [%s] 호가 연제 가능한 실제 매수 건수(%s건)가 부족하여 최적화 생략.", strat_upper, len(trades)) - return {"ok": False, "reason": "not_enough_trades", "trade_count": len(trades)} + n_raw = len(raw_fills) if raw_fills else 0 + lg.warning( + "⚠️ [%s] 호가 연제 가능한 실제 매수 건수(%s건, 체결원본=%s)가 부족하여 최적화 생략.", + strat_upper, len(trades), n_raw, + ) + return { + "ok": False, + "reason": "not_enough_trades", + "trade_count": len(trades), + "fill_count": n_raw, + } + + # 연동된 진입 스냅 벤더 요약 (후처리 원인파악) + try: + from collections import Counter as _Ctr + + _src_ctr = _Ctr() + for _ti in trades: + _es = _ti.entry_snaps[-1] if _ti.entry_snaps else None + _src_ctr[str((_es.source if _es else "") or "?").strip().lower() or "?"] += 1 + lg.info( + "🔎 [호가후처리] 연동체결=%d건 | 진입스냅벤더 %s", + len(trades), + " ".join(f"{k}={v}" for k, v in _src_ctr.most_common()), + ) + except Exception: + pass orig_stats = _ob_orig_stats(trades) orig_cnt = int(orig_stats["count"]) diff --git a/kis_trader/backtest/optuna_postprocess_topn.py b/kis_trader/backtest/optuna_postprocess_topn.py index 9fb84a3..749c554 100644 --- a/kis_trader/backtest/optuna_postprocess_topn.py +++ b/kis_trader/backtest/optuna_postprocess_topn.py @@ -28,7 +28,8 @@ def _krw_int(v: Any) -> int: return int(x) -def resolve_post_top_n(default: int = 5) -> int: +def resolve_post_top_n(default: int = 10) -> int: + """후처리·웹 Top 표 행 수. 기본 10 (사후/학습/안정 공통).""" return max(1, int(get_env_int("OPTUNA_POST_TOP_N", int(default)))) @@ -95,6 +96,7 @@ def _slim_ob(rec: Optional[Dict[str, Any]]) -> Dict[str, Any]: "ok": bool(rec.get("ok")), "reason": rec.get("reason") or "", "trade_count": int(rec.get("trade_count") or 0), + "fill_count": int(rec.get("fill_count") or 0), "params": dict(rec.get("params") or {}), "orig_stats": _slim_stats(rec.get("orig_stats")), "recommended_stats": _slim_stats(rec.get("recommended_stats")), @@ -337,6 +339,49 @@ def _median_params(params_list: List[Dict[str, Any]]) -> Dict[str, Any]: return cons +def _ob_8way_attempt_note(pool: List[Dict[str, Any]]) -> Tuple[bool, str]: + """8방 TPE를 돌렸는지(구JSON 아님)와 실패 사유. + + Returns: + (attempted, why) — attempted=True 이면 재실행만으로 8방이 안 채워질 수 있음. + """ + bits: List[str] = [] + saw_combos = False + for a in pool or []: + ob = a.get("orderbook") or {} + combos = ob.get("combos") + if isinstance(combos, dict) and combos: + saw_combos = True + r = str(ob.get("reason") or "").strip() + if r and r not in ("light_skip", "none"): + tc = ob.get("trade_count") + if r == "not_enough_trades" and tc is not None: + bit = "호가스냅%s건(<3)" % int(tc) + else: + bit = r + if bit not in bits: + bits.append(bit) + return (saw_combos or bool(bits), " · ".join(bits)) + + +def ob_8way_web_hint(topn: Optional[Dict[str, Any]]) -> str: + """웹 상태 한 줄. 8방이 채워졌으면 빈 문자열.""" + anchors = list((topn or {}).get("postprocess_by_anchor") or []) + pool = [a for a in anchors if str(a.get("role") or "") in ("gated", "mode")] + if not pool: + return "8방 없음" + if any( + isinstance((a.get("orderbook") or {}).get("combos"), dict) + and (a.get("orderbook") or {}).get("combos") + for a in pool + ): + return "" + attempted, why = _ob_8way_attempt_note(pool) + if attempted: + return why or "8방 미산출" + return "구JSON" + + def _anchor_combos_map(ob: Dict[str, Any]) -> Dict[str, Dict[str, Any]]: """앵커 orderbook → combo_id dict. 구 JSON은 e/x/s 단독만 복원.""" if not isinstance(ob, dict): @@ -400,8 +445,12 @@ def _axis_slice_from_combo( def _consensus_from_anchors(anchors: List[Dict[str, Any]], strategy: str) -> Dict[str, Any]: - """gated+mode. live·stable 제외. 호가=8방 중 median PnL 최고 방 + median 파라미터.""" - pool = [a for a in anchors if str(a.get("role") or "") in ("gated", "mode")] + """gated(+learn 폴백)+mode. live·stable 제외. 호가=8방 median PnL 최고 방.""" + # 사후합격 없으면 학습 Top 후처리(learn)를 합의에 넣음 — WR/PF 때문에 호가방 학습이 비지 않게 + roles = ("gated", "mode") + if not any(str(a.get("role") or "") == "gated" for a in (anchors or [])): + roles = ("gated", "learn", "mode") + pool = [a for a in anchors if str(a.get("role") or "") in roles] by_combo: Dict[str, List[Dict[str, Any]]] = {cid: [] for cid in _COMBO_IDS} for a in pool: combos = _anchor_combos_map(a.get("orderbook") or {}) @@ -429,7 +478,11 @@ def _consensus_from_anchors(anchors: List[Dict[str, Any]], strategy: str) -> Dic exit_c: Dict[str, Any] = {"ok": False, "params": {}, "n": 0} stop_c: Dict[str, Any] = {"ok": False, "params": {}, "n": 0} ob_merged: Dict[str, Any] = {} - note = "합의=Top5(+mode) 8방 중 median PnL 최고 방 + median 파라미터. 실매 참고행 제외." + note = ( + "합의=Top5(+mode) 8방 중 median PnL 최고 방 + median 파라미터. 실매 참고행 제외." + if any(str(a.get("role") or "") == "gated" for a in (anchors or [])) + else "합의=학습Top(+mode) — 사후합격(gated) 0건이라 WR/PF 미달 후보로 호가방 학습. 실매 참고행 제외." + ) if best_cid: recs = by_combo[best_cid] @@ -475,7 +528,15 @@ def _consensus_from_anchors(anchors: List[Dict[str, Any]], strategy: str) -> Dic ob_merged = dict(entry.get("params") or {}) ob_merged.update(exit_c.get("params") or {}) ob_merged.update(stop_c.get("params") or {}) - note = "합의=구JSON 축분리 median(8방 없음). 후처리 재실행 권장." + attempted, why = _ob_8way_attempt_note(pool) + if attempted: + note = ( + "합의=8방 유효 방 없음(%s). 축분리 median 폴백. " + "호가스냅이 늘지 않으면 재실행해도 동일." + % (why or "TPE 실패") + ) + else: + note = "합의=구JSON 축분리 median(8방 없음). 후처리 재실행 권장." ws_params_list = [ dict((a.get("whipsaw") or {}).get("params") or {}) @@ -635,6 +696,86 @@ def _reuse_ob_ws(anchors: List[Dict[str, Any]], trial: Any) -> Optional[Dict[str return None +def append_learn_postprocess_anchors( + data: Dict[str, Any], + anchors: List[Dict[str, Any]], + *, + strat: str, + strat_u: str, + top_n: int, + do_ob: bool, + evaluate_fn: Optional[EvalFn], + lg: logging.Logger, + ob_n_trials: int = 0, +) -> None: + """ + 사후합격(gated)이 비었을 때 학습 TopN(results)에도 호가/휩쏘 후처리. + + 탐색은 WR/PF=0 이라 −PnL도 TPE에 들어가는데, 후처리만 gated(WR40/PF1)에 + 묶이면 gated=0 일 때 호가 8방이 mode/live만 남고 학습 후보가 통째 제외됨. + → 후처리도 ‘끝난 뒤 후보 고르기’이지 ‘처음부터 제외’가 아니게 learn 폴백. + """ + if any(str(a.get("role") or "") == "gated" for a in anchors): + return + if any(str(a.get("role") or "") == "learn" for a in anchors): + return + learn = list( + (data or {}).get("results") + or (data or {}).get("results_all") + or [] + )[: max(1, int(top_n or 5))] + if not learn: + return + lg.info( + "📌 [후처리] 사후합격 0건 → 학습 Top%d 에 호가/휩쏘 후처리 (WR/PF 사후게이트와 분리)", + len(learn), + ) + for i, row in enumerate(learn, start=1): + if not isinstance(row, dict): + continue + combo = _combo_from_row(row) + fills: List[Dict[str, Any]] = [] + if do_ob: + from kis_trader.backtest import optuna_post_progress as opp + opp.next_unit(f"learn#{i}", "학습Top 호가") + if do_ob and evaluate_fn is not None: + lg.info("📌 [후처리] learn#%d 백테 재실행 (호가/휩쏘 체결)", i) + fills = _replay_fills(evaluate_fn, combo, lg) + pnl = row.get("total_pnl") + trail = _slim_trail(_trail_for_pnl(strat, pnl)) + ob = {"ok": False, "reason": "light_skip"} + ws = {"ok": False, "reason": "light_skip"} + if do_ob: + if fills: + ob = _ob_for_anchor( + strategy=strat_u, out_data=data, fills=fills, live=False, + lg=lg, n_trials=ob_n_trials, + ) + ws = _ws_for_anchor( + strategy=strat_u, out_data=data, fills=fills, live=False, lg=lg, + ) + elif evaluate_fn is None: + ob = {"ok": False, "reason": "no_replay_fills"} + ws = {"ok": False, "reason": "no_replay_fills"} + else: + ob = {"ok": False, "reason": "not_enough_trades", "trade_count": 0} + ws = {"ok": False, "reason": "not_enough_trades", "trade_count": 0} + anchors.append({ + "id": f"learn#{i}", + "role": "learn", + "rank": i, + "optuna_trial_number": row.get("optuna_trial_number") or row.get("_trial_number"), + "total_pnl": _krw_int(pnl), + "total_trades": int(row.get("total_trades") or 0), + "win_rate": row.get("win_rate"), + "pf": row.get("pf"), + "orderbook": ob, + "whipsaw": ws, + "trail": trail, + "note": "학습 Top(사후게이트 미달 폴백) — 호가방 학습용 · DB적용은 gated 우선", + }) + + def append_stable_postprocess_anchors( data: Dict[str, Any], anchors: List[Dict[str, Any]], @@ -652,7 +793,13 @@ def append_stable_postprocess_anchors( return if any(str(a.get("role") or "") == "stable" for a in anchors): return - stable = list((data or {}).get("results_stable") or [])[: max(1, int(top_n or 5))] + 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))) + # 후처리 중 JSON에 비어 있으면 재구성분 반영 (다음 요약·앵커 일치) + if not list((data or {}).get("results_stable") or []) and stable: + data["results_stable"] = list(stable) + if _meta: + data["stable_gates"] = dict(_meta) for i, row in enumerate(stable, start=1): if not isinstance(row, dict): continue @@ -724,7 +871,7 @@ def ensure_stable_postprocess_on_payload(data: Dict[str, Any]) -> None: before = len(anchors) append_stable_postprocess_anchors( data, anchors, - strat=strat, strat_u=strat_u, top_n=resolve_post_top_n(5), + strat=strat, strat_u=strat_u, top_n=resolve_post_top_n(10), do_ob=False, evaluate_fn=None, lg=logger, ob_n_trials=0, ) if len(anchors) != before: @@ -754,7 +901,7 @@ def attach_topn_postprocess( strat_u = "TAIL" elif strat_u in ("SCALPING", "SCALP"): strat_u = "SCALP" - top_n = resolve_post_top_n(5) + top_n = resolve_post_top_n(10) do_ob = _run_ob_whipsaw_full() if run_ob_whipsaw is None else bool(run_ob_whipsaw) gated = list(data.get("results_gated") or [])[:top_n] @@ -762,14 +909,40 @@ def attach_topn_postprocess( from kis_trader.backtest import optuna_post_progress as opp - stable_preview = list((data or {}).get("results_stable") or [])[: max(1, int(top_n or 5))] if _include_stable() else [] - n_units = len(gated) + (len(stable_preview) if _include_stable() else 0) + 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))) + if not list((data or {}).get("results_stable") or []) and stable_preview: + data["results_stable"] = list(stable_preview) + if _sg: + data["stable_gates"] = dict(_sg) + learn_preview: List[Any] = [] + if not gated: + learn_preview = list( + (data or {}).get("results") or (data or {}).get("results_all") or [] + )[: max(1, int(top_n or 5))] + n_units = len(gated) + len(learn_preview) + (len(stable_preview) if _include_stable() else 0) if _include_mode(): n_units += 1 if _include_live(): n_units += 1 if do_ob: opp.begin_job(lg, max(1, n_units)) + try: + from kis_trader.backtest.optuna_feed_trace import ( + log_bt_feed_chain_banner, + reset_bt_feed_sample_counter, + ) + + reset_bt_feed_sample_counter(postprocess=True) + log_bt_feed_chain_banner(context="Optuna후처리") + lg.info( + "🔎 [Optuna후처리] 호가 추천은 코어 TPE(필터OFF)와 별도 — " + "아래 [호가후처리]/[호가후처리샘플] 줄을 보면 됨" + ) + except Exception: + pass for i, row in enumerate(gated, start=1): combo = _combo_from_row(row) @@ -808,6 +981,13 @@ def attach_topn_postprocess( "note": "사후합격 후보", }) + # gated 비면 학습 Top 에도 호가 후처리 (탐색 WR/PF=0 과 같은 정신) + append_learn_postprocess_anchors( + data, anchors, + strat=strat, strat_u=strat_u, top_n=top_n, + do_ob=do_ob, evaluate_fn=evaluate_fn, lg=lg, ob_n_trials=ob_n_trials, + ) + append_stable_postprocess_anchors( data, anchors, strat=strat, strat_u=strat_u, top_n=top_n, diff --git a/kis_trader/backtest/optuna_rerun_postprocess.py b/kis_trader/backtest/optuna_rerun_postprocess.py index c0dbfb0..af6eca2 100644 --- a/kis_trader/backtest/optuna_rerun_postprocess.py +++ b/kis_trader/backtest/optuna_rerun_postprocess.py @@ -220,6 +220,33 @@ def rerun_postprocess_on_json(path: str, *, ob_n_trials: int = 0) -> Dict[str, A topn = data.get("postprocess_topn") or {} n = len(topn.get("postprocess_by_anchor") or []) logger.info("📌 후처리 재저장 %s anchors=%d overfit=%s", p, n, topn.get("apply_overfit_pct")) + try: + from kis_trader.backtest.optuna_study_store import ( + count_study_states, + save_payload, + ) + import optuna + from kis_trader.backtest.optuna_common import resolve_optuna_storage_url + + sname = str(data.get("optuna_study_name") or "") + n_c = int(data.get("optuna_n_complete") or 0) + if sname: + try: + st = optuna.load_study( + study_name=sname, + storage=resolve_optuna_storage_url(None), + ) + n_c, _nr, n_f = count_study_states(st) + except Exception: + n_f = n_c + save_payload( + study_name=sname, + out_data=data, + n_complete=n_f if n_f else n_c, + pp_status="done", + ) + except Exception as exc: + logger.warning("⚠️ optuna_study_result 반영 실패: %s", exc) return {"ok": True, "path": str(p), "anchors": n, "run_ob_whipsaw": True} finally: if ctx is not None: diff --git a/kis_trader/backtest/optuna_scalping.py b/kis_trader/backtest/optuna_scalping.py index eb66322..80d4e4a 100644 --- a/kis_trader/backtest/optuna_scalping.py +++ b/kis_trader/backtest/optuna_scalping.py @@ -175,6 +175,12 @@ def prepare_scalp_search_context( _hs = resolve_backtest_universe_history_source(history_source) base_fixed["_universe_history_source"] = _hs + try: + from kis_trader.backtest.optuna_feed_trace import log_bt_feed_chain_banner + + log_bt_feed_chain_banner(context="Optuna-SCALP") + except Exception: + pass codes_candles = _load_candles_for_search( start, end, rsi_period, history_source=_hs, ) @@ -388,6 +394,8 @@ def run_scalp_optuna( direction="maximize", sampler=_make_sampler(sampler_name, seed), ) + from kis_trader.backtest.optuna_study_store import bind_study_trials, finalize_optuna_export + bind_study_trials(study, n_trials=n_trials, log=logger) def objective(trial: optuna.Trial) -> float: if ctx.mode == "tpe": @@ -553,17 +561,28 @@ def run_scalp_optuna( out_path, strategy="scalp", mode=ctx.mode, note="mode_combo params 저장(실측 전)", log=logger, ) - enrich_out_data_with_mode_combo( - out_data, - evaluate_fn=_eval_mode, - grid_keys=ctx.grid_keys, + def _enrich() -> None: + enrich_out_data_with_mode_combo( + out_data, + evaluate_fn=_eval_mode, + grid_keys=ctx.grid_keys, + log=logger, + on_partial_save=_save_partial, + ) + with open(out_path, "w", encoding="utf-8") as f: + json.dump(out_data, f, indent=2, ensure_ascii=False) + announce_optuna_json_path( + out_path, strategy="scalp", mode=ctx.mode, note="최종 JSON", log=logger, + ) + + finalize_optuna_export( + study, + out_data=out_data, + out_path=out_path, + strategy="scalp", + mode=ctx.mode, + enrich_fn=_enrich, log=logger, - on_partial_save=_save_partial, - ) - with open(out_path, "w", encoding="utf-8") as f: - json.dump(out_data, f, indent=2, ensure_ascii=False) - announce_optuna_json_path( - out_path, strategy="scalp", mode=ctx.mode, note="최종 JSON", log=logger, ) study._kis_export_path = out_path # type: ignore[attr-defined] return study diff --git a/kis_trader/backtest/optuna_study_store.py b/kis_trader/backtest/optuna_study_store.py new file mode 100644 index 0000000..1d0e86f --- /dev/null +++ b/kis_trader/backtest/optuna_study_store.py @@ -0,0 +1,700 @@ +#!/usr/bin/env python3 +"""Optuna 스터디 결과 · 총 횟수 후처리 게이트 (kis_quant_db.optuna_study_result). + +kis_optuna 공식 studies/trials 는 건드리지 않는다. +실매 봇 루프에 넣지 않는다. +""" +from __future__ import annotations + +import json +import logging +import os +import socket +from typing import Any, Callable, Dict, Optional, Tuple + +logger = logging.getLogger("optuna_study_store") + +KIS_STUDY_TRIALS_ATTR = "kis_study_trials" + +_DDL = """ +CREATE TABLE IF NOT EXISTS optuna_study_result ( + study_name VARCHAR(255) NOT NULL COMMENT 'Optuna study 이름 (kis_optuna 과 동일)', + strategy VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'tail|momentum|us_momentum|breakout|scalp', + mode VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'tpe 등', + start_date VARCHAR(10) NULL COMMENT '백테 시작 YYYY-MM-DD', + end_date VARCHAR(10) NULL COMMENT '백테 종료 YYYY-MM-DD', + job_id VARCHAR(64) NULL COMMENT '웹 잡 id (참고)', + symbol VARCHAR(16) NULL COMMENT '해외 종목핀일 때만', + study_trials INT NOT NULL DEFAULT 0 COMMENT '스터디 총 시도 목표(COMPLETE+PRUNED+FAIL). 0이면 미설정', + n_complete INT NOT NULL DEFAULT 0 COMMENT '끝난 시도 수 캐시(COMPLETE+PRUNED+FAIL, 후처리 게이트)', + n_running INT NOT NULL DEFAULT 0 COMMENT '진행 중 trial 수 캐시 (표시용)', + payload_json LONGTEXT NULL COMMENT 'results_all/gated/stable, mode_combo, postprocess_topn, overfit (웹 요약과 동일 키)', + briefing_md MEDIUMTEXT NULL COMMENT '브리핑 마크다운', + pp_needed TINYINT NOT NULL DEFAULT 0 COMMENT '1=목표 도달, 후처리 대기', + pp_status VARCHAR(16) NOT NULL DEFAULT 'idle' COMMENT 'idle|running|done|error', + pp_for_n INT NOT NULL DEFAULT 0 COMMENT '이 후처리가 반영한 n_complete', + pp_lock_until DATETIME NULL COMMENT '락 만료 (죽은 워커 복구)', + pp_lock_owner VARCHAR(64) NULL COMMENT '잠근 호스트/pid', + pp_error VARCHAR(500) NULL COMMENT '후처리 실패 메시지', + leftover_note VARCHAR(200) NULL COMMENT '웹 남은횟수 안내 캐시', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '행 갱신', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '최초 생성', + PRIMARY KEY (study_name), + KEY idx_pp_needed (pp_needed, pp_status), + KEY idx_job (job_id), + KEY idx_updated (updated_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='Optuna 스터디별 웹 결과·후처리 산출 (파일 JSON 대체)' +""" + + +def _db(): + from database import TradeDB + + return TradeDB() + + +def ensure_optuna_study_result_table() -> None: + db = _db() + db.conn.execute_long(_DDL, timeout_sec=120) + + +def count_study_states(study: Any) -> Tuple[int, int, int]: + """(COMPLETE 수, RUNNING 수, 끝난 시도 수). + + 끝난 시도 = COMPLETE + PRUNED + FAIL. + 후처리·남은횟수 게이트는 「돌린 횟수」(끝난 시도) 기준. + 문제출제 오류(pruned: tp_max int: + """빈값/0 = 미사용. 음수 없음.""" + if raw is None: + return 0 + s = str(raw).strip() + if not s or s.lower() in ("none", "null"): + return 0 + try: + n = int(float(s)) + except (TypeError, ValueError): + return 0 + return max(0, n) + + +def resolve_cli_study_trials(cli_value: Any = None) -> int: + """CLI > 프로세스 env. DB 전역 기본 200 넣지 않음.""" + n = parse_study_trials_value(cli_value) + if n > 0: + return n + n = parse_study_trials_value(os.environ.get("KIS_OPTUNA_STUDY_TRIALS")) + if n > 0: + return n + n = parse_study_trials_value(os.environ.get("PARAM_SEARCH_OPTUNA_STUDY_TRIALS")) + if n > 0: + return n + try: + from kis_trader.utils.env import get_env_from_db + + n = parse_study_trials_value(get_env_from_db("PARAM_SEARCH_OPTUNA_STUDY_TRIALS", "") or "") + except Exception: + n = 0 + return n + + +def apply_kis_study_trials( + study: Any, + *, + n_trials: int, + log: Optional[logging.Logger] = None, + cli_study_trials: Optional[int] = None, +) -> int: + """첫 워커가 user_attr 기록. 이후 워커는 저장된 목표 유지(더 작은 값으로 덮지 않음).""" + lg = log or logger + requested = parse_study_trials_value( + cli_study_trials if cli_study_trials is not None else resolve_cli_study_trials() + ) + fallback = max(1, int(n_trials or 0)) + stored = 0 + try: + raw = (study.user_attrs or {}).get(KIS_STUDY_TRIALS_ATTR) + stored = parse_study_trials_value(raw) + except Exception: + stored = 0 + if stored > 0: + if requested > 0 and requested != stored: + lg.warning( + "⚠️ kis_study_trials=%s 유지 (요청 %s 무시 — 목표를 낮춰 조기 후처리하지 않음)", + stored, requested, + ) + return stored + target = requested if requested > 0 else fallback + try: + study.set_user_attr(KIS_STUDY_TRIALS_ATTR, int(target)) + except Exception as exc: + lg.warning("⚠️ kis_study_trials user_attr 저장 실패: %s", exc) + lg.info("📌 스터디 총 횟수 목표 kis_study_trials=%s (이번 --trials=%s)", target, n_trials) + return int(target) + + +def bind_study_trials(study: Any, *, n_trials: int, log: Optional[logging.Logger] = None) -> int: + return apply_kis_study_trials(study, n_trials=n_trials, log=log) + + +def leftover_trials(study_trials: int, n_finished: int) -> int: + """남은 횟수 = 목표 − 끝난 시도(COMPLETE+PRUNED+FAIL).""" + t = int(study_trials or 0) + c = int(n_finished or 0) + if t <= 0: + return 0 + return max(0, t - c) + + +def leftover_note( + study_trials: int, + n_finished: int, + *, + n_complete: Optional[int] = None, +) -> str: + left = leftover_trials(study_trials, n_finished) + t = int(study_trials or 0) + done = int(n_finished or 0) + ok = int(n_complete) if n_complete is not None else done + if t <= 0: + return "" + if left > 0: + return "남은 %s회 (시도 %s / 목표 %s · COMPLETE %s)" % (left, done, t, ok) + return "목표 도달 (시도 %s / 목표 %s · COMPLETE %s)" % (done, t, ok) + + +def _owner() -> str: + host = (socket.gethostname() or "host")[:40] + return ("%s:%s" % (host, os.getpid()))[:64] + + +def _lock_minutes() -> int: + try: + from kis_trader.utils.env import get_env_int + + return max(1, int(get_env_int("OPTUNA_STUDY_PP_LOCK_MINUTES", 30))) + except Exception: + return 30 + + +def load_row(study_name: str) -> Optional[Dict[str, Any]]: + name = str(study_name or "").strip() + if not name: + return None + try: + ensure_optuna_study_result_table() + cur = _db().conn.execute( + "SELECT * FROM optuna_study_result WHERE study_name=%s", + (name,), + ) + row = cur.fetchone() + return dict(row) if row else None + except Exception as exc: + logger.warning("⚠️ optuna_study_result 조회 실패: %s", exc) + return None + + +def load_payload_dict(study_name: str) -> Optional[Dict[str, Any]]: + row = load_row(study_name) + if not row: + return None + raw = row.get("payload_json") + if not raw: + return None + try: + data = json.loads(raw) + return data if isinstance(data, dict) else None + except Exception: + return None + + +def upsert_counts( + *, + study_name: str, + strategy: str = "", + mode: str = "", + start: str = "", + end: str = "", + job_id: str = "", + symbol: str = "", + study_trials: int = 0, + n_complete: int = 0, + n_running: int = 0, + leftover_n_complete: Optional[int] = None, +) -> None: + """n_complete = 끝난 시도(게이트). leftover_n_complete = COMPLETE 실수(안내).""" + name = str(study_name or "").strip() + if not name: + return + note = leftover_note( + study_trials, + n_complete, + n_complete=leftover_n_complete if leftover_n_complete is not None else None, + ) + needed = 1 if leftover_trials(study_trials, n_complete) > 0 else 0 + jid = (job_id or os.environ.get("OPTUNA_WEB_JOB_ID") or "")[:64] or None + sym = (symbol or "")[:16] or None + try: + ensure_optuna_study_result_table() + _db().conn.execute( + """ + INSERT INTO optuna_study_result ( + study_name, strategy, mode, start_date, end_date, job_id, symbol, + study_trials, n_complete, n_running, leftover_note, pp_needed + ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) + ON DUPLICATE KEY UPDATE + strategy=IF(VALUES(strategy)='', strategy, VALUES(strategy)), + mode=IF(VALUES(mode)='', mode, VALUES(mode)), + start_date=COALESCE(VALUES(start_date), start_date), + end_date=COALESCE(VALUES(end_date), end_date), + job_id=COALESCE(VALUES(job_id), job_id), + symbol=COALESCE(VALUES(symbol), symbol), + study_trials=IF(VALUES(study_trials)>0, VALUES(study_trials), study_trials), + n_complete=VALUES(n_complete), + n_running=VALUES(n_running), + leftover_note=VALUES(leftover_note), + pp_needed=VALUES(pp_needed) + """, + ( + name, + str(strategy or "")[:32], + str(mode or "")[:32], + (start or "")[:10] or None, + (end or "")[:10] or None, + jid, + sym, + int(study_trials or 0), + int(n_complete or 0), + int(n_running or 0), + note[:200] if note else None, + int(needed), + ), + ) + except Exception as exc: + logger.warning("⚠️ optuna_study_result upsert 실패: %s", exc) + + +def payload_has_rows(data: Optional[Dict[str, Any]]) -> bool: + if not isinstance(data, dict): + return False + if data.get("results_gated") or data.get("results_all") or data.get("results") or data.get("results_stable"): + return True + mc = data.get("mode_combo") or {} + return bool(isinstance(mc, dict) and (mc.get("params") or mc.get("backtest"))) + + +def payload_row_count(data: Optional[Dict[str, Any]]) -> int: + if not isinstance(data, dict): + return 0 + return max( + len(list(data.get("results_all") or [])), + len(list(data.get("results") or [])), + len(list(data.get("results_gated") or [])), + len(list(data.get("results_stable") or [])), + ) + + +def _payload_has_postprocess(data: Optional[Dict[str, Any]]) -> bool: + if not isinstance(data, dict): + return False + topn = data.get("postprocess_topn") or {} + if isinstance(topn, dict) and topn.get("postprocess_by_anchor"): + return True + mc = data.get("mode_combo") or {} + return bool(isinstance(mc, dict) and (mc.get("params") or mc.get("backtest"))) + + +def save_payload( + *, + study_name: str, + out_data: Dict[str, Any], + n_complete: int = 0, + briefing_text: Optional[str] = None, + pp_status: str = "idle", + pp_error: Optional[str] = None, + job_id: str = "", +) -> None: + name = str(study_name or "").strip() + if not name or not isinstance(out_data, dict): + return + payload = json.dumps(out_data, ensure_ascii=False) + brief = briefing_text + if brief is None: + bp = str(out_data.get("briefing_md_path") or "") + if bp and os.path.isfile(bp): + try: + with open(bp, encoding="utf-8") as f: + brief = f.read() + except Exception: + brief = None + md = str(out_data.get("briefing_md") or "") + if brief is None and md and str(md).endswith(".md") and os.path.isfile(md): + try: + with open(md, encoding="utf-8") as f: + brief = f.read() + except Exception: + brief = None + strat = str(out_data.get("strategy") or "")[:32] + mode = str(out_data.get("mode") or "")[:32] + start = (str(out_data.get("start") or "")[:10] or None) + end = (str(out_data.get("end") or "")[:10] or None) + jid = (job_id or os.environ.get("OPTUNA_WEB_JOB_ID") or "")[:64] or None + sym = str(out_data.get("symbol") or "")[:16] or None + st_goal = parse_study_trials_value(out_data.get("optuna_study_trials")) + # 게이트용 = 끝난 시도. COMPLETE만 쓰지 말 것(pruned 있으면 이어돌리기 오판). + n_done = int( + n_complete + or out_data.get("optuna_n_finished") + or out_data.get("optuna_trials_completed") + or out_data.get("optuna_n_complete") + or 0 + ) + n_ok = int(out_data.get("optuna_n_complete") or 0) + st = str(pp_status or "idle")[:16] + needed = 0 if st == "done" else (1 if leftover_trials(st_goal, n_done) > 0 else 0) + note = leftover_note(st_goal, n_done, n_complete=n_ok if n_ok else None) + try: + ensure_optuna_study_result_table() + _db().conn.execute( + """ + INSERT INTO optuna_study_result ( + study_name, strategy, mode, start_date, end_date, job_id, symbol, + study_trials, n_complete, leftover_note, payload_json, briefing_md, + pp_status, pp_error, pp_for_n, pp_needed + ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) + ON DUPLICATE KEY UPDATE + strategy=IF(VALUES(strategy)='', strategy, VALUES(strategy)), + mode=IF(VALUES(mode)='', mode, VALUES(mode)), + start_date=COALESCE(VALUES(start_date), start_date), + end_date=COALESCE(VALUES(end_date), end_date), + job_id=COALESCE(VALUES(job_id), job_id), + symbol=COALESCE(VALUES(symbol), symbol), + study_trials=IF(VALUES(study_trials)>0, VALUES(study_trials), study_trials), + n_complete=IF(VALUES(n_complete)>0, VALUES(n_complete), n_complete), + leftover_note=COALESCE(VALUES(leftover_note), leftover_note), + payload_json=VALUES(payload_json), + briefing_md=COALESCE(VALUES(briefing_md), briefing_md), + pp_status=VALUES(pp_status), + pp_error=VALUES(pp_error), + pp_for_n=IF(VALUES(pp_status)='done', VALUES(pp_for_n), pp_for_n), + pp_needed=IF(VALUES(pp_status)='done', 0, VALUES(pp_needed)), + pp_lock_until=IF(VALUES(pp_status)='done' OR VALUES(pp_status)='error', NULL, pp_lock_until), + pp_lock_owner=IF(VALUES(pp_status)='done' OR VALUES(pp_status)='error', NULL, pp_lock_owner) + """, + ( + name, strat, mode, start, end, jid, sym, + int(st_goal or 0), n_done, (note[:200] if note else None), payload, brief, + st, (str(pp_error)[:500] if pp_error else None), + n_done if st == "done" else 0, needed, + ), + ) + except Exception as exc: + logger.warning("⚠️ optuna_study_result payload 저장 실패: %s", exc) + + +def ingest_out_data( + data: Dict[str, Any], + *, + job_id: str = "", + replace: bool = False, +) -> Dict[str, Any]: + """JSON/out_data 한 건을 optuna_study_result 에 넣는다. 더 빈약한 파일로 덮지 않음.""" + if not isinstance(data, dict): + return {"ok": False, "reason": "not_dict"} + name = str(data.get("optuna_study_name") or data.get("study_name") or "").strip() + if not name: + return {"ok": False, "reason": "no_study_name"} + old = load_payload_dict(name) + if old and not replace: + old_n = payload_row_count(old) + new_n = payload_row_count(data) + old_pp = _payload_has_postprocess(old) + new_pp = _payload_has_postprocess(data) + if old_pp and not new_pp and old_n >= new_n: + return {"ok": True, "skipped": "keep_richer", "study_name": name} + if old_n > new_n and not new_pp: + return {"ok": True, "skipped": "keep_more_rows", "study_name": name} + n_done = int( + data.get("optuna_n_finished") + or data.get("optuna_trials_completed") + or 0 + ) + n_ok = int(data.get("optuna_n_complete") or 0) + if n_done <= 0: + n_done = n_ok or payload_row_count(data) or 0 + st_goal = parse_study_trials_value(data.get("optuna_study_trials")) + upsert_counts( + study_name=name, + strategy=str(data.get("strategy") or ""), + mode=str(data.get("mode") or ""), + start=str(data.get("start") or ""), + end=str(data.get("end") or ""), + job_id=job_id, + symbol=str(data.get("symbol") or ""), + study_trials=st_goal, + n_complete=n_done, + n_running=0, + leftover_n_complete=n_ok if n_ok else None, + ) + pp_done = _payload_has_postprocess(data) + save_payload( + study_name=name, + out_data=data, + n_complete=n_done, + pp_status="done" if pp_done else "idle", + job_id=job_id, + ) + return {"ok": True, "study_name": name, "n": payload_row_count(data), "pp": pp_done} + + +def migrate_optuna_json_dir( + results_dir: str, + *, + jobs_dir: str = "", +) -> Dict[str, Any]: + """results/*.json + 웹 잡 result_json 을 테이블로 이관.""" + from pathlib import Path + + seen = set() + ok_n = 0 + skip_n = 0 + err_n = 0 + paths = [] + rd = Path(results_dir) + if rd.is_dir(): + paths.extend(sorted(rd.glob("optuna_*.json"), key=lambda p: p.stat().st_mtime)) + jd = Path(jobs_dir) if jobs_dir else None + if jd and jd.is_dir(): + for jp in jd.glob("*.json"): + try: + meta = json.loads(jp.read_text(encoding="utf-8")) + except Exception: + continue + rp = str((meta or {}).get("result_json") or "") + if rp and Path(rp).is_file(): + paths.append(Path(rp)) + for extra in list((meta or {}).get("result_jsons") or []): + if extra and Path(str(extra)).is_file(): + paths.append(Path(str(extra))) + for p in paths: + try: + key = str(p.resolve()) + except Exception: + key = str(p) + if key in seen or str(p.name).endswith("_from_db.json"): + continue + seen.add(key) + try: + data = json.loads(p.read_text(encoding="utf-8")) + except Exception as exc: + err_n += 1 + logger.warning("⚠️ migrate JSON 읽기 실패 %s: %s", p, exc) + continue + out = ingest_out_data(data, job_id="") + if out.get("skipped"): + skip_n += 1 + elif out.get("ok"): + ok_n += 1 + else: + err_n += 1 + logger.warning("⚠️ migrate skip %s: %s", p.name, out.get("reason")) + return {"ok": True, "ingested": ok_n, "skipped": skip_n, "errors": err_n, "files": len(seen)} + + +def try_lock_postprocess(study_name: str) -> bool: + name = str(study_name or "").strip() + if not name: + return False + owner = _owner() + mins = _lock_minutes() + try: + ensure_optuna_study_result_table() + cur = _db().conn.execute( + f""" + UPDATE optuna_study_result + SET pp_status='running', + pp_lock_owner=%s, + pp_lock_until=DATE_ADD(NOW(), INTERVAL {int(mins)} MINUTE), + pp_error=NULL + WHERE study_name=%s + AND pp_status<>'running' + AND (pp_lock_until IS NULL OR pp_lock_until < NOW()) + """, + (owner, name), + ) + return int(getattr(cur, "rowcount", 0) or 0) > 0 + except Exception as exc: + logger.warning("⚠️ 후처리 락 실패: %s", exc) + return False + + +def set_study_trials_target(study_name: str, n: int, storage_url: Optional[str] = None) -> None: + """확정: 목표를 현재 완료 수로 내린다.""" + name = str(study_name or "").strip() + target = max(0, int(n or 0)) + if not name: + return + try: + ensure_optuna_study_result_table() + _db().conn.execute( + """ + UPDATE optuna_study_result + SET study_trials=%s, + leftover_note=%s, + pp_needed=0 + WHERE study_name=%s + """, + (target, leftover_note(target, target), name), + ) + except Exception as exc: + logger.warning("⚠️ study_trials 확정 실패: %s", exc) + if storage_url: + try: + import optuna + + st = optuna.load_study(study_name=name, storage=storage_url) + st.set_user_attr(KIS_STUDY_TRIALS_ATTR, target) + except Exception as exc: + logger.warning("⚠️ kis_study_trials user_attr 확정 실패: %s", exc) + + +def should_run_postprocess(n_finished: int, study_trials: int) -> bool: + """목표 도달 = 끝난 시도(COMPLETE+PRUNED+FAIL) ≥ kis_study_trials.""" + t = int(study_trials or 0) + if t <= 0: + return True + return int(n_finished or 0) >= t + + +def finalize_optuna_export( + study: Any, + *, + out_data: Dict[str, Any], + out_path: str, + strategy: str, + mode: str, + enrich_fn: Callable[[], None], + log: Optional[logging.Logger] = None, + symbol: str = "", +) -> bool: + """optimize 이후. 시도 목표 미달이면 후처리 스킵, 도달이면 락 후 기존 enrich.""" + lg = log or logger + name = str(getattr(study, "study_name", "") or out_data.get("optuna_study_name") or "") + n_c, n_r, n_f = count_study_states(study) + try: + target = parse_study_trials_value((study.user_attrs or {}).get(KIS_STUDY_TRIALS_ATTR)) + except Exception: + target = 0 + if target <= 0: + target = max(1, int(out_data.get("optuna_n_trials_requested") or 0) or n_f or n_c) + start = str(out_data.get("start") or "") + end = str(out_data.get("end") or "") + # DB n_complete 컬럼 = 끝난 시도(게이트·남은횟수). COMPLETE 실수는 out_data 에. + upsert_counts( + study_name=name, + strategy=str(out_data.get("strategy") or strategy or ""), + mode=str(out_data.get("mode") or mode or ""), + start=start, + end=end, + symbol=str(out_data.get("symbol") or symbol or ""), + study_trials=target, + n_complete=n_f, + n_running=n_r, + leftover_n_complete=n_c, + ) + out_data["optuna_study_trials"] = target + out_data["optuna_n_complete"] = n_c + out_data["optuna_n_finished"] = n_f + row = load_row(name) or {} + if str(row.get("pp_status") or "") == "done" and row.get("payload_json") and int(row.get("pp_for_n") or 0) >= target: + lg.info("📌 후처리 이미 완료 (pp_for_n=%s, 목표=%s) — 스킵", row.get("pp_for_n"), target) + return False + if not should_run_postprocess(n_f, target): + save_payload( + study_name=name, + out_data=out_data, + n_complete=n_f, + pp_status="idle", + ) + lg.info( + "📌 스터디 총 횟수 미달 — 후처리 스킵 " + "(시도 %s / 목표 %s · COMPLETE %s). 이어 돌리기 또는 확정.", + n_f, target, n_c, + ) + return False + if not try_lock_postprocess(name): + lg.info("📌 다른 워커가 후처리 중/완료 — 이 프로세스는 후처리 생략") + return False + try: + enrich_fn() + save_payload( + study_name=name, + out_data=out_data, + n_complete=n_f, + pp_status="done", + ) + lg.info( + "📌 optuna_study_result payload 저장 study=%s 시도=%s COMPLETE=%s", + name, n_f, n_c, + ) + return True + except Exception as exc: + save_payload( + study_name=name, + out_data=out_data, + n_complete=n_f, + pp_status="error", + pp_error=str(exc), + ) + raise + + +def flags_for_web(study_name: str) -> Dict[str, Any]: + row = load_row(study_name) or {} + t = int(row.get("study_trials") or 0) + # n_complete 컬럼 = 끝난 시도(게이트용). COMPLETE 실수는 leftover_note 참고. + done = int(row.get("n_complete") or 0) + left = leftover_trials(t, done) + pp = str(row.get("pp_status") or "idle") + has_payload = bool(row.get("payload_json")) + note = row.get("leftover_note") or leftover_note(t, done) + return { + "study_name": study_name, + "study_trials": t, + "n_complete": done, + "n_running": int(row.get("n_running") or 0), + "leftover_trials": left, + "leftover_note": note, + "pp_status": pp, + "pp_needed": int(row.get("pp_needed") or 0), + "has_payload": has_payload, + "can_continue": left > 0, + "can_confirm": left > 0, + } diff --git a/kis_trader/backtest/optuna_web_jobs.py b/kis_trader/backtest/optuna_web_jobs.py index 5461959..eb16f29 100644 --- a/kis_trader/backtest/optuna_web_jobs.py +++ b/kis_trader/backtest/optuna_web_jobs.py @@ -13,6 +13,7 @@ from __future__ import annotations import json import os import re +import shlex import signal import subprocess import threading @@ -29,6 +30,89 @@ PY = ROOT / ".venv" / "bin" / "python" _STRATS = ("momentum", "us_momentum", "tail", "breakout", "scalp") +def _result_study_name(meta: Optional[Dict[str, Any]]) -> str: + """테이블 PK. 순차 잡의 seq_* 이름은 쓰지 않고 현재/활성 study.""" + m = meta or {} + kind = str(m.get("kind") or "") + active = str(m.get("active_study_name") or "").strip() + study = str(m.get("study_name") or "").strip() + if kind in ("seq", "seq4") or study.startswith("seq_"): + return active + return active or study + + +def _attach_study_result_flags(m: Dict[str, Any]) -> None: + name = _result_study_name(m) + if not name: + m["can_continue"] = False + m["can_confirm"] = False + return + try: + from kis_trader.backtest.optuna_study_store import flags_for_web + + fl = flags_for_web(name) + except Exception: + fl = {} + m["study_trials"] = fl.get("study_trials") or m.get("study_trials") or 0 + m["n_complete"] = fl.get("n_complete") + m["leftover_trials"] = fl.get("leftover_trials") or 0 + 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") + m["can_continue"] = leftover_ok and not seq + m["can_confirm"] = leftover_ok and not seq + + +def _load_out_data_for_job( + meta: Optional[Dict[str, Any]], + path: Optional[str], +) -> tuple: + """테이블 payload(행 있음) 우선, 없으면 JSON 파일. (data, path).""" + from kis_trader.backtest.optuna_study_store import load_payload_dict, payload_has_rows + + name = _result_study_name(meta) + used = str(path or (meta or {}).get("result_json") or "") + file_data = None + if used and Path(used).is_file(): + try: + file_data = json.loads(Path(used).read_text(encoding="utf-8")) + except Exception: + file_data = None + tbl = None + if name: + try: + tbl = load_payload_dict(name) + except Exception: + tbl = None + if not name and isinstance(file_data, dict): + name = str(file_data.get("optuna_study_name") or file_data.get("study_name") or "").strip() + if name: + try: + tbl = load_payload_dict(name) + except Exception: + tbl = None + data = tbl if payload_has_rows(tbl) else None + if data is None: + data = file_data if payload_has_rows(file_data) else file_data + if not isinstance(data, dict): + raise FileNotFoundError("result_json 없음") + if used and Path(used).is_file(): + return data, used + stem = name or "optuna_study" + safe = re.sub(r"[^A-Za-z0-9._-]+", "_", stem)[:80] + outp = RESULTS_DIR / f"{safe}_from_db.json" + RESULTS_DIR.mkdir(parents=True, exist_ok=True) + outp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + if meta is not None: + meta["result_json"] = str(outp) + try: + save_job(meta) + except Exception: + pass + return data, str(outp) + + def _normalize_breakout_sl_modes(raw: Any) -> List[str]: """웹/CLI: fixed, atr. 빈값이면 fixed 1개.""" from kis_trader.backtest.optuna_breakout_tpe_space import ( @@ -151,7 +235,8 @@ def list_jobs(limit: int = 30, sort: str = "started") -> List[Dict[str, Any]]: def job_list_row(meta: Dict[str, Any]) -> Dict[str, Any]: """잡 목록 테이블용 슬림 행 — 후처리 JSON·브리핑·로그테일 제외.""" - m = meta or {} + m = dict(meta or {}) + _attach_study_result_flags(m) prog = m.get("progress") if isinstance(m.get("progress"), dict) else {} post = m.get("postprocess") if isinstance(m.get("postprocess"), dict) else {} return { @@ -163,6 +248,12 @@ def job_list_row(meta: Dict[str, Any]) -> Dict[str, Any]: "status": m.get("status"), "phase": m.get("phase"), "trials": m.get("trials"), + "study_trials": m.get("study_trials"), + "n_complete": m.get("n_complete"), + "leftover_trials": m.get("leftover_trials"), + "leftover_note": m.get("leftover_note") or "", + "can_continue": bool(m.get("can_continue")), + "can_confirm": bool(m.get("can_confirm")), "started_at": m.get("started_at"), "finished_at": m.get("finished_at"), "started_ts": m.get("started_ts"), @@ -176,6 +267,10 @@ def job_list_row(meta: Dict[str, Any]) -> Dict[str, Any]: "pct": post.get("pct"), "ready": post.get("ready"), }, + "join_cmd": m.get("join_cmd") or "", + "web_cmd": m.get("web_cmd") or m.get("cmd") 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 "", } @@ -403,6 +498,306 @@ def _pick_result_json_with_rows(paths: List[str]) -> Optional[str]: return scored[-1][1] +# us_momentum 을 momentum 보다 앞에 — [us_momentum] 이 momentum 으로 잘리지 않게 +_SEQ_START_RE = re.compile( + r"\[(?Pus_momentum|momentum|tail|breakout|scalp)" + r"(?:/(?P[a-z0-9_]+))?\] START" + r"(?:[^\n]*study=(?P[^\s]+))?" +) + + +def _seq_step_catalog(meta: Dict[str, Any]) -> List[Dict[str, str]]: + """순차 한 칸 = 전략(+꼬리 진입/+돌파 손절). 스크립트 run_one 과 동일 순서.""" + 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()] + tail_ems = list(meta.get("tail_entry_modes") or ["align"]) + bo_sms = list(meta.get("breakout_sl_modes") or ["fixed"]) + out: List[Dict[str, str]] = [] + for s in picked: + s = str(s or "").strip().lower() + if s == "tail": + for em in tail_ems: + out.append({"strategy": "tail", "extra": str(em or "align")}) + elif s == "breakout": + for sm in bo_sms: + out.append({"strategy": "breakout", "extra": str(sm or "fixed")}) + elif s: + out.append({"strategy": s, "extra": ""}) + return out + + +def _read_seq_active_sidecar(path: Optional[str]) -> Dict[str, str]: + """run_one 이 덮어쓰는 잡별 현재 study (전역 latest.study 보다 우선).""" + out: Dict[str, str] = {} + if not path: + return out + p = Path(path) + if not p.is_file(): + return out + try: + for line in p.read_text(encoding="utf-8").splitlines(): + if "=" not in line: + continue + k, _, v = line.partition("=") + k = k.strip().lower() + v = v.strip() + if k in ("strategy", "study", "entry_mode", "sl_mode", "extra"): + out[k] = v + except Exception: + return {} + extra = out.get("extra") or out.get("entry_mode") or out.get("sl_mode") or "" + if extra and "extra" not in out: + out["extra"] = extra + return out + + +def _last_seq_start_from_log(log_path: Optional[str]) -> Dict[str, str]: + """이 잡 마스터 로그에서 마지막 [strat] START study=… (끝 80줄만 보면 1번만 남음).""" + if not log_path: + return {} + p = Path(log_path) + if not p.is_file(): + return {} + try: + data = p.read_bytes() + if len(data) > 400_000: + data = data[-400_000:] + text = data.decode("utf-8", errors="replace") + except Exception: + return {} + hits = list(_SEQ_START_RE.finditer(text)) + if not hits: + return {} + m = hits[-1] + extra = str(m.group("extra") or "").strip() + return { + "strategy": str(m.group("strat") or "").strip().lower(), + "extra": extra, + "study": str(m.group("study") or "").strip(), + } + + +def _quote_cmd(argv: List[str]) -> str: + return " ".join(shlex.quote(str(x)) for x in argv) + + +def _ps_quote(s: str) -> str: + """PowerShell 인자. 공백·특수문자면 큰따옴표 + ` 이스케이프.""" + t = str(s) + if t == "": + return '""' + if re.search(r'[\s"\'$`&|;<>()]', t): + return '"' + t.replace("`", "``").replace('"', '`"') + '"' + return t + + +def _quote_ps(argv: List[str]) -> str: + return " ".join(_ps_quote(str(x)) for x in argv) + + +def _join_argv_for_study( + meta: Dict[str, Any], + *, + strategy: str, + study: str, + extra: str = "", + py_bin: str = "python3", +) -> List[str]: + """다른 PC용 — 상대경로. --trials 는 추가분. py_bin=python3|python.""" + strat = str(strategy or "").strip().lower() + mode = str(meta.get("mode") or "tpe").strip() or "tpe" + trials = str(int(meta.get("trials") or 200)) + start = str(meta.get("start") or "") + end = str(meta.get("end") or "") + hist = str(meta.get("universe_history_source") or "kiwoom").strip() or "kiwoom" + sort_by = "score" if strat in ("momentum", "us_momentum", "scalp") else "pnl" + argv = [ + py_bin, "-u", "kis_trader/backtest/param_search_optuna.py", + "--strategy", strat, + "--mode", mode, + "--start", start, + "--end", end, + "--trials", trials, + "--min_trades", "1", + "--min_win_rate", "0", + "--min_pf", "0", + "--orderbook-filter", "off", + "--no-progress", + "--study-name", study, + "--sort-by", sort_by, + "--universe-history-source", hist, + ] + st_goal = 0 + try: + st_goal = int(meta.get("study_trials") or 0) + except (TypeError, ValueError): + st_goal = 0 + leftover = 0 + try: + leftover = int(meta.get("leftover_trials") or 0) + except (TypeError, ValueError): + leftover = 0 + if st_goal <= 0: + try: + from kis_trader.backtest.optuna_study_store import flags_for_web + fl = flags_for_web(study) + st_goal = int(fl.get("study_trials") or 0) + leftover = int(fl.get("leftover_trials") or leftover) + except Exception: + pass + if st_goal > 0: + argv.extend(["--study-trials", str(st_goal)]) + if leftover > 0: + # --trials 는 이 프로세스 추가분 = 남은 횟수 + try: + idx = argv.index("--trials") + argv[idx + 1] = str(leftover) + except (ValueError, IndexError): + argv.extend(["--trials", str(leftover)]) + extra = str(extra or "").strip() + if strat == "tail": + ems = list(meta.get("tail_entry_modes") or ["align"]) + argv.extend(["--entry-mode", extra or str(ems[0] if ems else "align")]) + if strat == "breakout": + sms = list(meta.get("breakout_sl_modes") or ["fixed"]) + argv.extend(["--sl-mode", extra or str(sms[0] if sms else "fixed")]) + 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 strat == "us_momentum": + sym = str(meta.get("symbol") or "").strip().upper() + if sym: + argv.extend(["--symbol", sym]) + return argv + + +def _ps_join_script(argv: List[str]) -> str: + """Win11 PowerShell 한 덩어리. 레포 루트에서 실행.""" + from kis_trader.backtest.optuna_common import mariadb_creds + + host = str(mariadb_creds().get("host") or "192.168.0.141") + body = _quote_ps(argv) + return ( + "# 레포 루트로 이동한 뒤 붙여넣기 (git 커밋 = 웹 VM 과 동일)\n" + "$env:PYTHONUNBUFFERED = '1'\n" + "$env:PYTHONPATH = (Get-Location).Path\n" + f"$env:DB_HOST = '{host}'\n" + f"{body}\n" + ) + + +def build_optuna_join_payload(meta: Dict[str, Any]) -> Dict[str, Any]: + """웹 실행 명령 + 다른 PC에서 같은 study 에 붙는 python 명령.""" + m = meta or {} + kind = str(m.get("kind") or "") + is_seq = kind in ("seq", "seq4") + strat = str(m.get("current_strategy") or "").strip().lower() + if not strat: + raw = str(m.get("strategy") or "") + if "," in raw or raw in ("all", "seq"): + picked = list(m.get("strategies") or []) + strat = str(picked[0] or "").strip().lower() if picked else "" + else: + strat = raw.strip().lower() + extra = str( + m.get("current_sl_mode") or m.get("current_entry_mode") or "" + ).strip() + study = str(m.get("active_study_name") or "").strip() + if not is_seq: + study = study or str(m.get("study_name") or "").strip() + if study.startswith("seq_"): + study = str(m.get("active_study_name") or "").strip() + web_argv = m.get("cmd_argv") + if isinstance(web_argv, list) and web_argv: + web_cmd = _quote_cmd([str(x) for x in web_argv]) + else: + web_cmd = str(m.get("cmd") or "").strip() + hints = [ + "레포 루트 · 웹과 같은 git 커밋 · MariaDB 141/kis_optuna.", + "PowerShell 은 python (python3 아님). --trials 는 이 PC 추가분, --study-trials 는 스터디 총 완료 목표.", + "자잘한 VM 10대는 틱 RAM 이 10번 복제됩니다. Win PC 1대 + 기존 VM 이 현실적입니다.", + ] + if is_seq: + hints.append( + "순차 웹 bash 를 그대로 돌리면 새 study. 아래 python 만 복사." + ) + join_cmd = "" + join_cmd_ps = "" + if strat and study: + argv_sh = _join_argv_for_study( + m, strategy=strat, study=study, extra=extra, py_bin="python3" + ) + argv_ps = _join_argv_for_study( + 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 후 다시 여세요.") + join_all: List[Dict[str, str]] = [] + log_path = str(m.get("log_path") or "") + if is_seq and log_path: + 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() + for hit in _SEQ_START_RE.finditer(text): + st = str(hit.group("strat") or "").strip().lower() + ex = str(hit.group("extra") or "").strip() + sy = str(hit.group("study") or "").strip() + if not st or not sy or sy in seen: + continue + seen.add(sy) + a_sh = _join_argv_for_study( + m, strategy=st, study=sy, extra=ex, py_bin="python3" + ) + a_ps = _join_argv_for_study( + m, strategy=st, study=sy, extra=ex, py_bin="python" + ) + join_all.append({ + "strategy": st, + "extra": ex, + "study": sy, + "cmd": _quote_cmd(a_sh), + "cmd_ps": _ps_join_script(a_ps), + }) + except Exception: + join_all = [] + return { + "web_cmd": web_cmd, + "join_cmd": join_cmd, + "join_cmd_ps": join_cmd_ps, + "join_hint": "\n".join(hints), + "join_study": study, + "join_cmds_all": join_all, + } + + +def _apply_seq_active(m: Dict[str, Any], info: Dict[str, str]) -> None: + strat = str(info.get("strategy") or "").strip().lower() + extra = str(info.get("extra") or info.get("entry_mode") or info.get("sl_mode") or "").strip() + study = str(info.get("study") or "").strip() + if strat: + m["current_strategy"] = strat + if extra: + if strat == "breakout": + m["current_entry_mode"] = extra + m["current_sl_mode"] = extra + else: + m["current_entry_mode"] = extra + if study: + m["active_study_name"] = study + + def _study_progress(study_name: str, trials_total: int) -> Dict[str, Any]: """Optuna MariaDB study 기준 진행률 + best trial 실측 지표. @@ -455,7 +850,13 @@ def _study_progress(study_name: str, trials_total: int) -> Dict[str, Any]: out["best_value"] = None out["study_ok"] = True except Exception as exc: - out["error"] = str(exc)[:200] + msg = str(exc) + # 스터디 create 전(캔들/틱 로드) load_study 실패는 오류가 아님 + low = msg.lower() + if "does not exist" in low or "not found" in low: + out["error"] = None + else: + out["error"] = msg[:200] return out @@ -499,15 +900,28 @@ def _row_metrics( def _summarize_result_json(path: Optional[str]) -> Optional[Dict[str, Any]]: - """완료 JSON → Top5 learn/gated/stable · 행별 과적합% · vs_best.""" + """완료 JSON → TopN learn/gated/stable · 행별 과적합% · vs_best.""" 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 + return _summarize_result_data(data, path=path) + + +def _summarize_result_data( + data: Optional[Dict[str, Any]], + path: Optional[str] = None, +) -> Optional[Dict[str, Any]]: + if not isinstance(data, dict): + return None + from kis_trader.backtest.optuna_postprocess_topn import resolve_post_top_n + + top_n = resolve_post_top_n(10) gated = list(data.get("results_gated") or []) - stable = list(data.get("results_stable") or []) + from kis_trader.backtest.optuna_common import resolve_results_stable + stable, stable_gates_resolved = resolve_results_stable(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 @@ -541,21 +955,21 @@ def _summarize_result_json(path: Optional[str]) -> Optional[Dict[str, Any]]: }) top5 = [] - for i, row in enumerate(gated[:5], start=1): + for i, row in enumerate(gated[:top_n], start=1): m = _row_metrics(row, label=f"gated #{i}", source="gated", data=data) if m: m["rank"] = i top5.append(m) top5_learn: List[Dict[str, Any]] = [] - for i, row in enumerate(allr[:5], start=1): + for i, row in enumerate(allr[:top_n], start=1): m = _row_metrics(row, label=f"learn #{i}", source="learn", data=data) if m: m["rank"] = i top5_learn.append(m) top5_stable: List[Dict[str, Any]] = [] - for i, row in enumerate(stable[:5], start=1): + for i, row in enumerate(stable[:top_n], start=1): m = _row_metrics(row, label=f"stable #{i}", source="stable", data=data) if m: m["rank"] = i @@ -623,7 +1037,20 @@ def _summarize_result_json(path: Optional[str]) -> Optional[Dict[str, Any]]: "n_stable": len(stable), "n_all": len(allr), "optuna_best_trial_number": data.get("optuna_best_trial_number"), - "stable_gates": data.get("stable_gates"), + "stable_gates": stable_gates_resolved or data.get("stable_gates"), + "mode_combo_summary": { + "ok": bool(mc_bt.get("ok") or mc_bt.get("total_pnl") is not None), + "total_pnl": mc_bt.get("total_pnl"), + "total_trades": mc_bt.get("total_trades"), + "win_rate": mc_bt.get("win_rate"), + "pf": mc_bt.get("pf"), + "note": mc.get("note"), + "method": mc.get("method"), + "top_n": mc.get("top_n"), + "pool_size": mc.get("pool_size"), + "vs_best": vs if vs else None, + "has_params": bool(mc.get("params")), + }, "top": _row_metrics( top, label="적용후보(gated우선)", @@ -685,6 +1112,19 @@ def _resolve_single_strategy( return "" +def _pool_for_optuna_source(data: Dict[str, Any], src: str) -> List[Dict[str, Any]]: + """gated / learn(results) / stable(비면 학습풀 재구성) 후보 풀.""" + s = str(src or "gated").strip().lower() + if s == "stable": + from kis_trader.backtest.optuna_common import resolve_results_stable + from kis_trader.backtest.optuna_postprocess_topn import resolve_post_top_n + pool, _ = resolve_results_stable(data, top_n=resolve_post_top_n(10)) + return list(pool or []) + if s == "gated": + return list(data.get("results_gated") or []) + return list(data.get("results") or data.get("results_all") or []) + + def get_candidate_detail( *, job_id: Optional[str] = None, @@ -704,9 +1144,7 @@ def get_candidate_detail( if not meta: raise FileNotFoundError(f"job not found: {job_id}") path = path or meta.get("result_json") - if not path or not Path(path).is_file(): - raise FileNotFoundError("result_json 없음") - data = json.loads(Path(path).read_text(encoding="utf-8")) + data, path = _load_out_data_for_job(meta, path) src = str(source or "gated").strip().lower() rank = max(1, int(rank or 1)) params: Dict[str, Any] = {} @@ -725,13 +1163,7 @@ def get_candidate_detail( "pf": bt.get("pf"), } else: - if src == "stable": - pool = data.get("results_stable") - elif src == "gated": - pool = data.get("results_gated") - else: - pool = data.get("results") or [] - pool = list(pool or []) + pool = _pool_for_optuna_source(data, src) if not pool: raise RuntimeError(f"{src} 결과 없음") if rank > len(pool): @@ -809,10 +1241,7 @@ def apply_optuna_result( if meta.get("status") != "done": raise RuntimeError(f"job status={meta.get('status')} — 완료 후에만 적용") path = path or meta.get("result_json") - if not path or not Path(path).is_file(): - raise FileNotFoundError("result_json 없음") - - data = json.loads(Path(path).read_text(encoding="utf-8")) + data, path = _load_out_data_for_job(meta, path) strat = _resolve_single_strategy(meta, data, path) if strat not in _STRATS: raise ValueError(f"전략 불명: {strat or (meta or {}).get('strategy')}") @@ -848,13 +1277,7 @@ def apply_optuna_result( if not merged: raise RuntimeError("mode_combo.params 없음") else: - if src == "stable": - pool = data.get("results_stable") - elif src == "gated": - pool = data.get("results_gated") - else: - pool = data.get("results") or [] - pool = list(pool or []) + pool = _pool_for_optuna_source(data, src) if not pool: raise RuntimeError(f"{src} 결과 없음") if rank > len(pool): @@ -1082,6 +1505,12 @@ def register_result_json_as_job( "imported_at": _now_iso(), }) meta["result_summary"] = _summarize_result_json(str(path)) + try: + from kis_trader.backtest.optuna_study_store import ingest_out_data + + ingest_out_data(data, job_id=job_id) + except Exception: + pass if briefing.is_file(): try: meta["briefing_preview"] = briefing.read_text(encoding="utf-8")[:4000] @@ -1157,52 +1586,47 @@ def refresh_job_status(meta: Dict[str, Any]) -> Dict[str, Any]: # 순차(seq/seq4): 마스터 로그는 START/DONE만 찍힘 → trial 로그·study는 전략별 파일 active_log = log_path if m.get("kind") in ("seq4", "seq") and log_path: - # master + 전략 로그에 START 줄이 흩어질 수 있어 둘 다 스캔 - master_blob = (_tail_text(log_path, 80) or "") + "\n" - try: - side = ROOT / "logs" / "optuna_4strat_tpe_latest_master.logpath" - if side.is_file(): - master_file = Path(side.read_text(encoding="utf-8").strip()) - if master_file.is_file(): - master_blob += _tail_text(str(master_file), 80) or "" - except Exception: - pass - # 로그: [tail] START 또는 [tail/align] START study=... - hits = re.findall( - r"\[(momentum|us_momentum|tail|breakout|scalp)(?:/([a-z0-9_]+))?\] START(?:[^\n]*study=([^\s]+))?", - master_blob, - ) - if hits: - strat_h, em_h, study_h = hits[-1] - m["current_strategy"] = strat_h - if em_h: - m["current_entry_mode"] = em_h - if study_h: - m["active_study_name"] = study_h.strip() - prog = _study_progress( - m["active_study_name"], int(m.get("trials") or 0) - ) + # 1) 이 잡 전용 sidecar (run_one 시작 시 덮어씀) 2) 이 잡 로그의 마지막 START + # 전역 optuna_*_tpe_latest.study 는 이전 전략에 남을 수 있어 쓰지 않음 + side_info = _read_seq_active_sidecar(str(m.get("seq_active_file") or "")) + log_info = _last_seq_start_from_log(log_path) + if side_info.get("study") or side_info.get("strategy"): + _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: - # 전략별 로그/study 사이드카 (스크립트가 갱신) - for side_name, key in ( - (f"optuna_{cs}_tpe_latest.logpath", "active_log_path"), - (f"optuna_{cs}_tpe_latest.study", "active_study_name"), - ): - sp = ROOT / "logs" / side_name - try: - if sp.is_file(): - val = sp.read_text(encoding="utf-8").strip() - if val: - m[key] = val - except Exception: - pass - if m.get("active_study_name") and not prog.get("study_ok"): - prog = _study_progress( - str(m["active_study_name"]), int(m.get("trials") or 0) - ) - if m.get("active_log_path") and Path(str(m["active_log_path"])).is_file(): - active_log = str(m["active_log_path"]) + 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"): + prog = _study_progress( + str(m["active_study_name"]), int(m.get("trials") or 0) + ) + catalog = _seq_step_catalog(m) + if catalog: + prog["seq_steps"] = len(catalog) + cur = str(m.get("current_strategy") or "").strip().lower() + extra = str( + m.get("current_sl_mode") or m.get("current_entry_mode") or "" + ).strip() + step_i = 0 + for i, row in enumerate(catalog, start=1): + if row["strategy"] != cur: + continue + if row["extra"] and extra and row["extra"] != extra: + continue + step_i = i + if step_i: + prog["seq_step"] = step_i + if m.get("active_log_path") and Path(str(m["active_log_path"])).is_file(): + active_log = str(m["active_log_path"]) + master_blob = (_tail_text(log_path, 40) or "") if "ALL DONE" in master_blob: m["status"] = "done" alive = False @@ -1212,7 +1636,15 @@ def refresh_job_status(meta: Dict[str, Any]) -> Dict[str, Any]: m["finished_at"] = None elif not alive: # 프로세스 종료 - if m.get("result_json") and Path(str(m["result_json"])).is_file(): + _has_tbl = False + try: + from kis_trader.backtest.optuna_study_store import load_payload_dict + _has_tbl = bool(load_payload_dict(_result_study_name(m))) + except Exception: + _has_tbl = False + if m.get("result_json") and Path(str(m.get("result_json"))).is_file(): + m["status"] = "done" + elif _has_tbl: m["status"] = "done" elif m.get("kind") in ("seq4", "seq") and m.get("status") != "done": tail = _tail_text(log_path, 20) @@ -1261,7 +1693,8 @@ 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") == "done": + if m.get("status") == "done" and not alive: + # 순차: 1번 스터디 200/200 이어도 프로세스가 살아 있으면 아직 다음 전략 prog["pct"] = 100.0 if prog["trials_total"] and prog["trials_done"] < prog["trials_total"]: prog["trials_done"] = prog["trials_total"] @@ -1274,7 +1707,22 @@ def refresh_job_status(meta: Dict[str, Any]) -> Dict[str, Any]: m["display_log_path"] = active_log else: m["display_log_path"] = log_path or None - m["result_summary"] = _summarize_result_json(m.get("result_json")) + prev_sum = m.get("result_summary") if isinstance(m.get("result_summary"), dict) else None + try: + _data, _pth = _load_out_data_for_job(m, m.get("result_json")) + if _pth: + m["result_json"] = _pth + sm = _summarize_result_data(_data, path=_pth) + if sm and (sm.get("top") or sm.get("n_all") or sm.get("n_gated")): + m["result_summary"] = sm + elif prev_sum: + m["result_summary"] = prev_sum + else: + m["result_summary"] = sm + except Exception: + sm = _summarize_result_json(m.get("result_json")) + m["result_summary"] = sm or prev_sum + _attach_study_result_flags(m) 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] @@ -1289,11 +1737,21 @@ def refresh_job_status(meta: Dict[str, Any]) -> Dict[str, Any]: if not alive and str((rerun or {}).get("status") or "") != "running": post["ready"] = True post["pct"] = 100.0 - post["hint"] = "후처리 끝 · 「상세」 가능" + from kis_trader.backtest.optuna_postprocess_topn import ob_8way_web_hint + miss8 = ob_8way_web_hint(topn) + post["hint"] = ( + ("후처리 끝 · 8방 미산출(%s) · 「상세」 가능" % miss8) + if miss8 else "후처리 끝 · 「상세」 가능" + ) post["stage"] = post.get("stage") or "done" trials_tot = int(prog.get("trials_total") or 0) trials_done = int(prog.get("trials_done") or 0) - trial_finished = bool(trials_tot and trials_done >= trials_tot) + is_seq = m.get("kind") in ("seq4", "seq") + if is_seq and alive: + # 앞 전략 trial 이 가득 차도 다음 전략이 남음 → 후처리 페이즈로 올리지 않음 + trial_finished = False + else: + trial_finished = bool(trials_tot and trials_done >= trials_tot) rerun_run = str((rerun or {}).get("status") or "") == "running" if rerun_run: m["phase"] = "postprocess" @@ -1313,8 +1771,14 @@ def refresh_job_status(meta: Dict[str, Any]) -> Dict[str, Any]: if post.get("ready") and not post.get("hint"): post["hint"] = "후처리 끝 · 「상세」 가능" m["postprocess"] = post + if str(m.get("status") or "") == "done" and int(m.get("leftover_trials") or 0) > 0: + m["phase"] = "leftover" + post["ready"] = False + post["hint"] = m.get("leftover_note") or "목표 미달 · 이어 돌리기 또는 확정" + m["postprocess"] = post save_job(m) + m.update(build_optuna_join_payload(m)) return m @@ -1404,6 +1868,8 @@ def start_optuna_job( ob_source: Optional[str] = None, entry_modes: Optional[Any] = None, sl_modes: Optional[Any] = None, + study_trials: Optional[int] = None, + study_name_override: Optional[str] = None, ) -> Dict[str, Any]: """ subprocess 로 Optuna 시작. apply-best 없음. @@ -1432,6 +1898,9 @@ def start_optuna_job( picked = _normalize_strategies(strategy=strategy, strategies=strategies) mode = str(mode or "tpe").strip().lower() or "tpe" trials = max(1, min(2000, int(trials or 200))) + 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() start = str(start or "").strip() end = str(end or "").strip() if not start or not end: @@ -1481,6 +1950,7 @@ def start_optuna_job( bo_sms = ["fixed"] bo_dual = "breakout" in picked and len(bo_sms) >= 2 use_seq = len(picked) >= 2 or tail_dual or bo_dual + seq_active = None if use_seq: job_id = f"opt_{ts}_seq" @@ -1490,9 +1960,15 @@ def start_optuna_job( "bash", str(ROOT / "scripts" / "run_optuna_4strat_tpe_seq.sh"), ] + seq_active = ROOT / "logs" / f"{job_id}_seq_active.txt" env["START"] = start env["END"] = end env["TRIALS"] = str(trials) + env["OPTUNA_SEQ_ACTIVE_FILE"] = str(seq_active) + if st_goal > 0: + env["STUDY_TRIALS"] = str(st_goal) + env["KIS_OPTUNA_STUDY_TRIALS"] = str(st_goal) + env["PARAM_SEARCH_OPTUNA_STUDY_TRIALS"] = str(st_goal) env["MODE"] = mode env["MIN_WIN_RATE"] = "0" env["MIN_PF"] = "0" @@ -1561,6 +2037,10 @@ def start_optuna_job( "--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": @@ -1579,7 +2059,17 @@ def start_optuna_job( kind = "single" strat_field = strat + if reuse_study: + if use_seq: + raise ValueError("이어 돌리기는 단일 전략만 가능 (시작 폼의 새 study 가 아님)") + study_name = reuse_study + if "--study-name" in cmd: + _i = cmd.index("--study-name") + if _i + 1 < len(cmd): + cmd[_i + 1] = study_name + log_f = open(log_path, "w", encoding="utf-8") + env["OPTUNA_WEB_JOB_ID"] = job_id # start_new_session: 세션 분리. 부모 wait 필수(reaper) — 없으면 좀비(Z). proc = subprocess.Popen( cmd, @@ -1599,7 +2089,9 @@ def start_optuna_job( "start": start, "end": end, "trials": trials, + "study_trials": st_goal, "study_name": study_name, + "seq_active_file": str(seq_active) if use_seq else None, "symbol": sym or None, "universe_history_source": hist_src, "candle_source": candle_source, @@ -1618,7 +2110,8 @@ def start_optuna_job( "briefing_md": None, "error": None, "apply_best": False, - "cmd": " ".join(cmd)[:500], + "cmd_argv": [str(x) for x in cmd], + "cmd": _quote_cmd([str(x) for x in cmd]), } save_job(meta) _spawn_job_reaper(proc, job_id, log_f) @@ -1627,6 +2120,73 @@ def start_optuna_job( return refresh_job_status(meta) +def continue_optuna_job(job_id: str) -> Dict[str, Any]: + """같은 study_name 으로 남은 횟수만 웹 잡 새로 띄움.""" + 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("status") or "") != "done": + raise RuntimeError("끝난 잡만 이어 돌리기 가능") + name = _result_study_name(meta) + if not name: + raise RuntimeError("study 이름 없음") + from kis_trader.backtest.optuna_study_store import flags_for_web + + fl = flags_for_web(name) + left = int(fl.get("leftover_trials") or 0) + goal = int(fl.get("study_trials") or 0) + if left <= 0 or goal <= 0: + raise RuntimeError("남은 횟수 없음 (이미 목표 도달)") + picked = list(meta.get("strategies") or []) + strat = str(meta.get("strategy") or "") + return start_optuna_job( + strategy=strat if ("," not in strat and strat not in ("all", "seq")) else None, + strategies=picked or None, + start=str(meta.get("start") or ""), + end=str(meta.get("end") or ""), + trials=left, + mode=str(meta.get("mode") or "tpe"), + symbol=meta.get("symbol"), + universe_history_source=meta.get("universe_history_source"), + candle_source=meta.get("candle_source"), + tick_source=meta.get("tick_source"), + ob_source=meta.get("ob_source"), + entry_modes=meta.get("tail_entry_modes"), + sl_modes=meta.get("breakout_sl_modes"), + study_trials=goal, + study_name_override=name, + ) + + +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("status") or "") != "done": + raise RuntimeError("끝난 잡만 확정 가능") + name = _result_study_name(meta) + if not name: + raise RuntimeError("study 이름 없음") + from kis_trader.backtest.optuna_study_store import flags_for_web, set_study_trials_target + from kis_trader.backtest.optuna_common import resolve_optuna_storage_url + + fl = flags_for_web(name) + n_c = int(fl.get("n_complete") or 0) + if n_c <= 0: + raise RuntimeError("완료 trial 이 없어 확정할 수 없음") + set_study_trials_target(name, n_c, storage_url=resolve_optuna_storage_url(None)) + _data, path = _load_out_data_for_job(meta, meta.get("result_json")) + meta["result_json"] = path + meta["study_trials"] = n_c + save_job(meta) + return start_postprocess_rerun(job_id) + + def start_postprocess_rerun(job_id: str) -> Dict[str, Any]: """완료 잡의 result JSON 에 축별 후처리를 백그라운드로 다시 붙인다. 실매 DB 미적용.""" meta = load_job(job_id) @@ -1642,7 +2202,9 @@ def start_postprocess_rerun(job_id: str) -> Dict[str, Any]: pp = dict(meta.get("postprocess_rerun") or {}) if str(pp.get("status") or "") == "running" and _pid_alive(pp.get("pid")): raise RuntimeError("이미 이 잡 후처리 재실행 중") - path = str(meta.get("result_json") or "") + _data, path = _load_out_data_for_job(meta, meta.get("result_json")) + meta["result_json"] = path + save_job(meta) if not path or not Path(path).is_file(): raise FileNotFoundError("result_json 없음") ts = datetime.now().strftime("%Y%m%d_%H%M%S") diff --git a/kis_trader/backtest/param_search_momentum.py b/kis_trader/backtest/param_search_momentum.py index 74d8bed..1a044c9 100644 --- a/kis_trader/backtest/param_search_momentum.py +++ b/kis_trader/backtest/param_search_momentum.py @@ -1814,15 +1814,14 @@ def _load_candles_for_search( if str(c).strip() } - from kis_trader.backtest.bt_candle_source import ( - fetch_ws_candles_for_code, - list_ws_candle_codes, - ) + from kis_trader.backtest.bt_candle_source import fetch_ws_candles_by_code_bulk - codes = list_ws_candle_codes( + loaded = fetch_ws_candles_by_code_bulk( db, 1, start_key, end_key, market=mk if mk in ("US", "KR") else None, + confirmed_only=True, ) + codes = list(loaded.keys()) if mk == "US": try: from permanent_subs import codes_by_market as _perm_us @@ -1834,13 +1833,10 @@ def _load_candles_for_search( if want: codes = [c for c in codes if str(c).upper() in want] + min_bars = int(rsi_period) + 5 for code in codes: - rows = fetch_ws_candles_for_code( - db, code, 1, start_key, end_key, - market=mk if mk in ("US", "KR") else None, - confirmed_only=True, - ) - if len(rows) < rsi_period + 5: + rows = loaded.get(code) or [] + if len(rows) < min_bars: continue codes_candles[code] = rows if codes_candles: diff --git a/kis_trader/backtest/param_search_optuna.py b/kis_trader/backtest/param_search_optuna.py index 098ebed..db76f24 100644 --- a/kis_trader/backtest/param_search_optuna.py +++ b/kis_trader/backtest/param_search_optuna.py @@ -296,6 +296,12 @@ def prepare_tail_search_context( f"유니버스={universe_source} | 매수시간 " f"{base_params.get('time_start_hm', 930):04d}-{base_params.get('time_end_hm', 1500):04d}" ) + try: + from kis_trader.backtest.optuna_feed_trace import log_bt_feed_chain_banner + + log_bt_feed_chain_banner(context="Optuna-TAIL") + except Exception: + pass rsi_period = int(base_params.get("rsi_period", 14)) candles_by_code, total_candles, has_holding_peak = tbc.load_tail_candles_by_code( @@ -475,6 +481,8 @@ def run_tail_optuna( direction=direction, sampler=sampler, ) + from kis_trader.backtest.optuna_study_store import bind_study_trials, finalize_optuna_export + bind_study_trials(study, n_trials=n_trials, log=logger) def objective(trial: optuna.Trial) -> float: if ctx.mode == "tpe": @@ -646,20 +654,31 @@ def run_tail_optuna( out_path, strategy="tail", mode=ctx.mode, note="mode_combo params 저장(실측 전)", log=logger, ) - enrich_out_data_with_mode_combo( - out_data, - evaluate_fn=_eval_mode, - grid_keys=ctx.grid_keys, + def _enrich() -> None: + enrich_out_data_with_mode_combo( + out_data, + evaluate_fn=_eval_mode, + grid_keys=ctx.grid_keys, + log=logger, + on_partial_save=_save_partial, + ) + try: + with open(out_path, "w", encoding="utf-8") as f: + json.dump(out_data, f, indent=2, ensure_ascii=False) + except OSError as exc: + logger.warning("⚠️ mode_combo 반영 재저장 실패: %s", exc) + announce_optuna_json_path( + out_path, strategy="tail", mode=ctx.mode, note="최종 JSON", log=logger, + ) + + finalize_optuna_export( + study, + out_data=out_data, + out_path=out_path, + strategy="tail", + mode=ctx.mode, + enrich_fn=_enrich, log=logger, - on_partial_save=_save_partial, - ) - try: - with open(out_path, "w", encoding="utf-8") as f: - json.dump(out_data, f, indent=2, ensure_ascii=False) - except OSError as exc: - logger.warning("⚠️ mode_combo 반영 재저장 실패: %s", exc) - announce_optuna_json_path( - out_path, strategy="tail", mode=ctx.mode, note="최종 JSON", log=logger, ) if study.best_trial and study.best_value > _FAIL_OBJECTIVE + 1: @@ -749,6 +768,10 @@ def main() -> None: "--study-name", default=None, dest="study_name", help="Study 이름 (미지정 시 OPTUNA_TAIL_STUDY_NAME 또는 tail_{mode}_{start}_{end})", ) + parser.add_argument( + "--study-trials", default=None, type=int, dest="study_trials", + help="이 study 시도 목표(COMPLETE+PRUNED+FAIL). 미지정/0=이번 --trials. 시도 < 목표면 후처리 스킵", + ) parser.add_argument( "--storage", default=None, help="Optuna storage URL (미지정 시 MariaDB 141/kis_optuna)", @@ -848,6 +871,11 @@ def main() -> None: n_trials = get_env_int("PARAM_SEARCH_OPTUNA_N_TRIALS", 200) n_trials = max(1, int(n_trials)) + from kis_trader.backtest.optuna_study_store import resolve_cli_study_trials + _st_goal = resolve_cli_study_trials(getattr(args, "study_trials", None)) + if _st_goal > 0: + os.environ["KIS_OPTUNA_STUDY_TRIALS"] = str(_st_goal) + n_jobs = args.n_jobs if n_jobs is None: n_jobs = get_env_int("PARAM_SEARCH_OPTUNA_N_JOBS", 1) diff --git a/kis_trader/backtest/results/optuna_breakout_tpe_20260821_114315.briefing.md b/kis_trader/backtest/results/optuna_breakout_tpe_20260821_114315.briefing.md new file mode 100644 index 0000000..31b2933 --- /dev/null +++ b/kis_trader/backtest/results/optuna_breakout_tpe_20260821_114315.briefing.md @@ -0,0 +1,46 @@ +# Optuna 브리핑 — 돌파 (tpe) +기간: 2026-08-18 ~ 2026-08-20 (거래일≈3일) · trials 200/200 +탐색 게이트: WR≥0% PF≥0 · min_trades≥1 | 후보 게이트: WR≥40% PF≥1 · min_trades≥1 +- min_trades=1: **합격 인정 최소 거래 건수**(표본 부족·우연 승률 방지). 이보다 적은 조합은 학습/후보 집계에서 제외됩니다. +학습용 통과 193건 · 사후합격(results_gated) 78건 + +## 과적합·적용 가능도 (휴리스틱) +- **과적합 위험 59.0%** · **적용 가능도 41.0%** · 판정: **주의** — 적용 가능도 중간 — 다일(≥5일) 재검증·웹백테 후 소액만 + - 표본 장일: +16.0p — 거래일≈3일 — 다일 재검증 권장(≥5일) + - 거래 표본: +18.0p — 상위 후보 거래 3건 — 표본 부족 + - 승률·PF 이상치: +25.0p — 승률 100.0% + 거래 3건 · PF 9999.00 (소수 거래 폭증) + - 게이트 커버: +0.0p — 사후합격 78건 · stable 59건 + - PnL 고원: +0.0p — 고원 비율 1% (1건) +- 임계값 분포 표본: results_gated 상위 30건 +| 파라미터 | median | p25~p75 | mode(비율) | +|---|---:|---|---| +| `max_daily_chg` | 24.5 | 22.0 ~ 30.0 | 21.0 (13%) | +| `vol_mult` | 1.8 | 1.4 ~ 2.1 | 1.4 (13%) | +| `vol_window` | 1.0 | 1.0 ~ 2.0 | 1 (67%) | +| `min_turnover_1m_pct` | 0.3 | 0.25 ~ 0.3875 | 0.3 (27%) | +| `prev_chg_min` | 0.9 | 0.9 ~ 1.0 | 1.0 (43%) | +| `prev_chg_max` | 11.0 | 8.25 ~ 17.25 | 11.0 (13%) | +| `min_price` | 4000.0 | 3000.0 ~ 4000.0 | 4000 (37%) | +| `tp_pct` | 13.5 | 9.5 ~ 16.375 | 14.5 (7%) | +| `sl_mode` | — | — | fixed (100%) | +| `trail_pct` | 4.2 | 3.6 ~ 4.675 | 4.2 (10%) | +| `trail_arm_pct` | 4.0 | 3.1 ~ 4.55 | 2.8 (7%) | +| `shoulder_min_high_pct` | 3.2 | 2.425 ~ 4.5 | 2.7 (10%) | +- _참고: 과적합%는 교차검증 점수가 아니라 표본·이상치·고원 휴리스틱입니다. 적용 가능도%=100−과적합위험%. DB 적용 버튼 활성 조건(gated PnL>0)과는 별개입니다._ + +## 이전 장에서는 +- 실매 동기간 청산 8건 · 승률 50% · 합산 PnL 258원 (백테 Optuna와 숫자 달라도 정상 — 고스트·유니버스·틱 경로 차이). +- 사후합격 1위: trial #80 · 거래 3건 · 승률 100.0% · PF 9999.00 · PnL 27,539원 +- 승률이 비정상적으로 높고 표본이 작습니다 → **해당 장에만 맞는 과적합** 신호. + +## 앞으로 장에서는 +- **1~2일 Optuna best를 바로 실매에 넣지 마세요.** 다일(≥5거래일) 재검증·웹백테 1회 후 검토. +- 적용 시에는 `results_gated` 후보만. 탐색용 best(objective)와 다를 수 있습니다. +- 장이 좁아지거나 변동성이 커지면: 손절·트레일·시간창이 먼저 깨집니다. 승률 자랑보다 **손절이 살아 있는지**를 보세요. +- 사후합격이 있어도 ‘내일도 같은 장’이 아닙니다. 페이퍼/소액·모의로 1~2일 관찰 후 확대하세요. + +_생성: 2026-08-21 11:48:18 (rule)_ + +## AI 코멘트 + +_(미생성: api_call_failed. 구글 키는 GEMINI_API_KEY 를 DB/환경에 넣으세요 — kis_trader/backtest/optuna_briefing.py 모듈 docstring 참고)_ diff --git a/kis_trader/backtest/results/optuna_breakout_tpe_20260821_120015.briefing.md b/kis_trader/backtest/results/optuna_breakout_tpe_20260821_120015.briefing.md new file mode 100644 index 0000000..ff05e5d --- /dev/null +++ b/kis_trader/backtest/results/optuna_breakout_tpe_20260821_120015.briefing.md @@ -0,0 +1,46 @@ +# Optuna 브리핑 — 돌파 (tpe) +기간: 2026-08-18 ~ 2026-08-20 (거래일≈3일) · trials 200/200 +탐색 게이트: WR≥0% PF≥0 · min_trades≥1 | 후보 게이트: WR≥40% PF≥1 · min_trades≥1 +- min_trades=1: **합격 인정 최소 거래 건수**(표본 부족·우연 승률 방지). 이보다 적은 조합은 학습/후보 집계에서 제외됩니다. +학습용 통과 175건 · 사후합격(results_gated) 51건 + +## 과적합·적용 가능도 (휴리스틱) +- **과적합 위험 66.0%** · **적용 가능도 34.0%** · 판정: **주의** — 적용 가능도 중간 — 다일(≥5일) 재검증·웹백테 후 소액만 + - 표본 장일: +16.0p — 거래일≈3일 — 다일 재검증 권장(≥5일) + - 거래 표본: +25.0p — 상위 후보 거래 1건 — 우연 승·과적합 가능 + - 승률·PF 이상치: +25.0p — 승률 100.0% + 거래 1건 · PF 9999.00 (소수 거래 폭증) + - 게이트 커버: +0.0p — 사후합격 51건 · stable 14건 + - PnL 고원: +0.0p — 고원 비율 2% (1건) +- 임계값 분포 표본: results_gated 상위 30건 +| 파라미터 | median | p25~p75 | mode(비율) | +|---|---:|---|---| +| `max_daily_chg` | 25.0 | 23.0 ~ 27.0 | 27.0 (23%) | +| `vol_mult` | 2.4 | 2.125 ~ 2.775 | 2.3 (13%) | +| `vol_window` | 10.5 | 9.25 ~ 12.0 | 10 (23%) | +| `min_turnover_1m_pct` | 0.25 | 0.2 ~ 0.35 | 0.25 (23%) | +| `prev_chg_min` | 0.9 | 0.9 ~ 1.0 | 1.0 (40%) | +| `prev_chg_max` | 17.75 | 13.375 ~ 22.75 | 23.0 (10%) | +| `min_price` | 2000.0 | 1500.0 ~ 2875.0 | 1000 (23%) | +| `tp_pct` | 10.75 | 6.25 ~ 15.0 | 3.0 (7%) | +| `sl_mode` | — | — | atr (100%) | +| `trail_pct` | 0.65 | 0.225 ~ 1.2 | 0.1 (20%) | +| `trail_arm_pct` | 2.8 | 2.125 ~ 3.875 | 2.1 (10%) | +| `shoulder_min_high_pct` | 4.4 | 3.0 ~ 5.3 | 5.3 (10%) | +- _참고: 과적합%는 교차검증 점수가 아니라 표본·이상치·고원 휴리스틱입니다. 적용 가능도%=100−과적합위험%. DB 적용 버튼 활성 조건(gated PnL>0)과는 별개입니다._ + +## 이전 장에서는 +- 실매 동기간 청산 8건 · 승률 50% · 합산 PnL 258원 (백테 Optuna와 숫자 달라도 정상 — 고스트·유니버스·틱 경로 차이). +- 사후합격 1위: trial #141 · 거래 1건 · 승률 100.0% · PF 9999.00 · PnL 10,752원 +- 승률이 비정상적으로 높고 표본이 작습니다 → **해당 장에만 맞는 과적합** 신호. + +## 앞으로 장에서는 +- **1~2일 Optuna best를 바로 실매에 넣지 마세요.** 다일(≥5거래일) 재검증·웹백테 1회 후 검토. +- 적용 시에는 `results_gated` 후보만. 탐색용 best(objective)와 다를 수 있습니다. +- 장이 좁아지거나 변동성이 커지면: 손절·트레일·시간창이 먼저 깨집니다. 승률 자랑보다 **손절이 살아 있는지**를 보세요. +- 사후합격이 있어도 ‘내일도 같은 장’이 아닙니다. 페이퍼/소액·모의로 1~2일 관찰 후 확대하세요. + +_생성: 2026-08-21 12:01:54 (rule)_ + +## AI 코멘트 + +_(미생성: api_call_failed. 구글 키는 GEMINI_API_KEY 를 DB/환경에 넣으세요 — kis_trader/backtest/optuna_briefing.py 모듈 docstring 참고)_ diff --git a/kis_trader/backtest/results/optuna_momentum_tpe_20260820_121708.briefing.md b/kis_trader/backtest/results/optuna_momentum_tpe_20260820_121708.briefing.md new file mode 100644 index 0000000..7e02d71 --- /dev/null +++ b/kis_trader/backtest/results/optuna_momentum_tpe_20260820_121708.briefing.md @@ -0,0 +1,46 @@ +# Optuna 브리핑 — 모멘텀 (tpe) +기간: 2026-08-17 ~ 2026-08-20 (거래일≈4일) · trials 15/5 +탐색 게이트: WR≥0% PF≥0 · min_trades≥1 | 후보 게이트: WR≥40% PF≥1 · min_trades≥1 +- min_trades=1: **합격 인정 최소 거래 건수**(표본 부족·우연 승률 방지). 이보다 적은 조합은 학습/후보 집계에서 제외됩니다. +학습용 통과 10건 · 사후합격(results_gated) 0건 + +## 과적합·적용 가능도 (휴리스틱) +- **과적합 위험 28.0%** · **적용 가능도 72.0%** · 판정: **상대적으로낮음** — 휴리스틱상 위험 상대적 낮음 — 그래도 다일 확인 권장 + - 표본 장일: +16.0p — 거래일≈4일 — 다일 재검증 권장(≥5일) + - 거래 표본: +0.0p — 상위 후보 거래 29건 — 상대적 양호 + - 승률·PF 이상치: +0.0p — 이상치 없음 + - 게이트 커버: +12.0p — 사후합격 0건 (학습 10) — DB 적용 비권장 + - PnL 고원: +0.0p — 고원 비율 10% (1건) +- 임계값 분포 표본: results(learning) 상위 10건 +| 파라미터 | median | p25~p75 | mode(비율) | +|---|---:|---|---| +| `use_ema_filter` | 0.0 | 0.0 ~ 0.0 | False (80%) | +| `ema_fast_period` | 10.0 | 8.5 ~ 12.5 | 10 (30%) | +| `ema_slow_period` | 26.0 | 19.25 ~ 34.5 | 15 (20%) | +| `mom_vol_mult` | 7.1 | 4.925 ~ 8.0 | 8.1 (10%) | +| `mom_vol_win` | 4.5 | 3.25 ~ 8.25 | 3 (30%) | +| `e_min_chg_pct` | 1.4 | 0.625 ~ 2.025 | 1.6 (10%) | +| `tp_pct` | 3.65 | 2.375 ~ 6.35 | 5.0 (10%) | +| `tp_max_pct` | 14.0 | 13.125 ~ 16.25 | 14.0 (20%) | +| `sl_pct` | 2.1 | 1.25 ~ 3.775 | 1.7 (20%) | +| `shoulder_min_high` | 2.9 | 2.275 ~ 3.4 | 2.2 (10%) | +| `shoulder_cut_pct` | 0.325 | 0.05 ~ 0.525 | 0.05 (40%) | +| `trail_pct` | 2.35 | 0.85 ~ 3.575 | 3.7 (10%) | +- _참고: 과적합%는 교차검증 점수가 아니라 표본·이상치·고원 휴리스틱입니다. 적용 가능도%=100−과적합위험%. DB 적용 버튼 활성 조건(gated PnL>0)과는 별개입니다._ + +## 이전 장에서는 +- 실매 동기간 청산 51건 · 승률 33% · 합산 PnL 104,435원 (백테 Optuna와 숫자 달라도 정상 — 고스트·유니버스·틱 경로 차이). +- 학습 1위(사후게이트 미달 가능): trial #13 · 거래 29건 · 승률 41.4% · PF 0.75 · PnL -25,068원 +- 플러스/학습 후보는 있으나 승률·PF 사후 기준은 못 넘김 → **당장 DB 적용은 비권장.** + +## 앞으로 장에서는 +- **1~2일 Optuna best를 바로 실매에 넣지 마세요.** 다일(≥5거래일) 재검증·웹백테 1회 후 검토. +- 적용 시에는 `results_gated` 후보만. 탐색용 best(objective)와 다를 수 있습니다. +- 장이 좁아지거나 변동성이 커지면: 손절·트레일·시간창이 먼저 깨집니다. 승률 자랑보다 **손절이 살아 있는지**를 보세요. +- 현재 사후합격 0건 → 파라미터 유지(현행 DB) + 유니버스/데이터 점검이 우선입니다. + +_생성: 2026-08-20 12:17:45 (rule)_ + +## AI 코멘트 + +_(미생성: api_call_failed. 구글 키는 GEMINI_API_KEY 를 DB/환경에 넣으세요 — kis_trader/backtest/optuna_briefing.py 모듈 docstring 참고)_ diff --git a/kis_trader/backtest/results/optuna_momentum_tpe_20260821_104243.briefing.md b/kis_trader/backtest/results/optuna_momentum_tpe_20260821_104243.briefing.md new file mode 100644 index 0000000..ca0fc3a --- /dev/null +++ b/kis_trader/backtest/results/optuna_momentum_tpe_20260821_104243.briefing.md @@ -0,0 +1,45 @@ +# Optuna 브리핑 — 모멘텀 (tpe) +기간: 2026-08-18 ~ 2026-08-20 (거래일≈3일) · trials 200/200 +탐색 게이트: WR≥0% PF≥0 · min_trades≥1 | 후보 게이트: WR≥40% PF≥1 · min_trades≥1 +- min_trades=1: **합격 인정 최소 거래 건수**(표본 부족·우연 승률 방지). 이보다 적은 조합은 학습/후보 집계에서 제외됩니다. +학습용 통과 188건 · 사후합격(results_gated) 21건 + +## 과적합·적용 가능도 (휴리스틱) +- **과적합 위험 16.0%** · **적용 가능도 84.0%** · 판정: **상대적으로낮음** — 휴리스틱상 위험 상대적 낮음 — 그래도 다일 확인 권장 + - 표본 장일: +16.0p — 거래일≈3일 — 다일 재검증 권장(≥5일) + - 거래 표본: +0.0p — 상위 후보 거래 30건 — 상대적 양호 + - 승률·PF 이상치: +0.0p — 이상치 없음 + - 게이트 커버: +0.0p — 사후합격 21건 · stable 3건 + - PnL 고원: +0.0p — 고원 비율 5% (1건) +- 임계값 분포 표본: results_gated 상위 21건 +| 파라미터 | median | p25~p75 | mode(비율) | +|---|---:|---|---| +| `use_ema_filter` | 0.0 | 0.0 ~ 0.0 | False (95%) | +| `ema_fast_period` | 9.0 | 8.0 ~ 10.0 | 10 (29%) | +| `ema_slow_period` | 33.0 | 30.0 ~ 39.0 | 30 (24%) | +| `mom_vol_mult` | 9.1 | 8.8 ~ 9.2 | 9.2 (33%) | +| `mom_vol_win` | 5.0 | 5.0 ~ 5.0 | 5 (100%) | +| `e_min_chg_pct` | 1.9 | 1.8 ~ 2.1 | 1.7 (19%) | +| `tp_pct` | 10.3 | 9.7 ~ 11.1 | 11.4 (19%) | +| `tp_max_pct` | 15.5 | 14.0 ~ 16.0 | 16.0 (24%) | +| `sl_pct` | 6.9 | 6.4 ~ 7.2 | 7.3 (19%) | +| `shoulder_min_high` | 4.5 | 4.4 ~ 4.6 | 4.6 (29%) | +| `shoulder_cut_pct` | 0.2 | 0.2 ~ 0.25 | 0.2 (52%) | +| `trail_pct` | 3.2 | 2.9 ~ 3.3 | 3.3 (24%) | +- _참고: 과적합%는 교차검증 점수가 아니라 표본·이상치·고원 휴리스틱입니다. 적용 가능도%=100−과적합위험%. DB 적용 버튼 활성 조건(gated PnL>0)과는 별개입니다._ + +## 이전 장에서는 +- 실매 동기간 청산 53건 · 승률 32% · 합산 PnL 104,435원 (백테 Optuna와 숫자 달라도 정상 — 고스트·유니버스·틱 경로 차이). +- 사후합격 1위: trial #103 · 거래 30건 · 승률 53.3% · PF 1.16 · PnL 19,877원 + +## 앞으로 장에서는 +- **1~2일 Optuna best를 바로 실매에 넣지 마세요.** 다일(≥5거래일) 재검증·웹백테 1회 후 검토. +- 적용 시에는 `results_gated` 후보만. 탐색용 best(objective)와 다를 수 있습니다. +- 장이 좁아지거나 변동성이 커지면: 손절·트레일·시간창이 먼저 깨집니다. 승률 자랑보다 **손절이 살아 있는지**를 보세요. +- 사후합격이 있어도 ‘내일도 같은 장’이 아닙니다. 페이퍼/소액·모의로 1~2일 관찰 후 확대하세요. + +_생성: 2026-08-21 10:51:32 (rule)_ + +## AI 코멘트 + +_(미생성: api_call_failed. 구글 키는 GEMINI_API_KEY 를 DB/환경에 넣으세요 — kis_trader/backtest/optuna_briefing.py 모듈 docstring 참고)_ diff --git a/kis_trader/backtest/results/optuna_momentum_tpe_20260821_151059.briefing.md b/kis_trader/backtest/results/optuna_momentum_tpe_20260821_151059.briefing.md new file mode 100644 index 0000000..4b7d43a --- /dev/null +++ b/kis_trader/backtest/results/optuna_momentum_tpe_20260821_151059.briefing.md @@ -0,0 +1,46 @@ +# Optuna 브리핑 — 모멘텀 (tpe) +기간: 2026-08-18 ~ 2026-08-20 (거래일≈3일) · trials 200/200 +탐색 게이트: WR≥0% PF≥0 · min_trades≥1 | 후보 게이트: WR≥40% PF≥1 · min_trades≥1 +- min_trades=1: **합격 인정 최소 거래 건수**(표본 부족·우연 승률 방지). 이보다 적은 조합은 학습/후보 집계에서 제외됩니다. +학습용 통과 185건 · 사후합격(results_gated) 0건 + +## 과적합·적용 가능도 (휴리스틱) +- **과적합 위험 28.0%** · **적용 가능도 72.0%** · 판정: **상대적으로낮음** — 휴리스틱상 위험 상대적 낮음 — 그래도 다일 확인 권장 + - 표본 장일: +16.0p — 거래일≈3일 — 다일 재검증 권장(≥5일) + - 거래 표본: +0.0p — 상위 후보 거래 53건 — 상대적 양호 + - 승률·PF 이상치: +0.0p — 이상치 없음 + - 게이트 커버: +12.0p — 사후합격 0건 (학습 185) — DB 적용 비권장 + - PnL 고원: +0.0p — 고원 비율 1% (1건) +- 임계값 분포 표본: results(learning) 상위 30건 +| 파라미터 | median | p25~p75 | mode(비율) | +|---|---:|---|---| +| `use_ema_filter` | 1.0 | 1.0 ~ 1.0 | True (83%) | +| `ema_fast_period` | 14.0 | 12.0 ~ 15.0 | 15 (37%) | +| `ema_slow_period` | 40.0 | 37.25 ~ 42.0 | 41 (17%) | +| `mom_vol_mult` | 6.9 | 6.3 ~ 7.3 | 7.7 (13%) | +| `mom_vol_win` | 3.0 | 3.0 ~ 4.0 | 3 (70%) | +| `e_min_chg_pct` | 0.9 | 0.7 ~ 1.2 | 1.2 (17%) | +| `tp_pct` | 1.5 | 1.5 ~ 1.6 | 1.5 (57%) | +| `tp_max_pct` | 7.0 | 4.5 ~ 9.375 | 3.5 (13%) | +| `sl_pct` | 2.85 | 2.125 ~ 3.875 | 1.7 (7%) | +| `shoulder_min_high` | 3.2 | 3.0 ~ 3.7 | 3.2 (17%) | +| `shoulder_cut_pct` | 0.3 | 0.25 ~ 0.3375 | 0.3 (40%) | +| `trail_pct` | 0.95 | 0.625 ~ 1.175 | 1.1 (17%) | +- _참고: 과적합%는 교차검증 점수가 아니라 표본·이상치·고원 휴리스틱입니다. 적용 가능도%=100−과적합위험%. DB 적용 버튼 활성 조건(gated PnL>0)과는 별개입니다._ + +## 이전 장에서는 +- 실매 동기간 청산 53건 · 승률 32% · 합산 PnL 104,435원 (백테 Optuna와 숫자 달라도 정상 — 고스트·유니버스·틱 경로 차이). +- 학습 1위(사후게이트 미달 가능): trial #143 · 거래 53건 · 승률 45.3% · PF 0.79 · PnL -29,365원 +- 플러스/학습 후보는 있으나 승률·PF 사후 기준은 못 넘김 → **당장 DB 적용은 비권장.** + +## 앞으로 장에서는 +- **1~2일 Optuna best를 바로 실매에 넣지 마세요.** 다일(≥5거래일) 재검증·웹백테 1회 후 검토. +- 적용 시에는 `results_gated` 후보만. 탐색용 best(objective)와 다를 수 있습니다. +- 장이 좁아지거나 변동성이 커지면: 손절·트레일·시간창이 먼저 깨집니다. 승률 자랑보다 **손절이 살아 있는지**를 보세요. +- 현재 사후합격 0건 → 파라미터 유지(현행 DB) + 유니버스/데이터 점검이 우선입니다. + +_생성: 2026-08-21 15:12:24 (rule)_ + +## AI 코멘트 + +_(미생성: api_call_failed. 구글 키는 GEMINI_API_KEY 를 DB/환경에 넣으세요 — kis_trader/backtest/optuna_briefing.py 모듈 docstring 참고)_ diff --git a/kis_trader/backtest/results/optuna_scalp_tpe_20260821_122053.briefing.md b/kis_trader/backtest/results/optuna_scalp_tpe_20260821_122053.briefing.md new file mode 100644 index 0000000..62020f8 --- /dev/null +++ b/kis_trader/backtest/results/optuna_scalp_tpe_20260821_122053.briefing.md @@ -0,0 +1,46 @@ +# Optuna 브리핑 — 스캘핑 (tpe) +기간: 2026-08-18 ~ 2026-08-20 (거래일≈3일) · trials 200/200 +탐색 게이트: WR≥0% PF≥0 · min_trades≥1 | 후보 게이트: WR≥40% PF≥1 · min_trades≥1 +- min_trades=1: **합격 인정 최소 거래 건수**(표본 부족·우연 승률 방지). 이보다 적은 조합은 학습/후보 집계에서 제외됩니다. +학습용 통과 143건 · 사후합격(results_gated) 28건 + +## 과적합·적용 가능도 (휴리스틱) +- **과적합 위험 72.0%** · **적용 가능도 28.0%** · 판정: **비권장** — 과적합·표본부족 위험 높음 — 실매 DB 즉시 적용 비권장 + - 표본 장일: +16.0p — 거래일≈3일 — 다일 재검증 권장(≥5일) + - 거래 표본: +25.0p — 상위 후보 거래 1건 — 우연 승·과적합 가능 + - 승률·PF 이상치: +25.0p — 승률 100.0% + 거래 1건 · PF 9999.00 (소수 거래 폭증) + - 게이트 커버: +0.0p — 사후합격 28건 · stable 2건 + - PnL 고원: +6.0p — PnL 고원 8/28 (29%) +- 임계값 분포 표본: results_gated 상위 28건 +| 파라미터 | median | p25~p75 | mode(비율) | +|---|---:|---|---| +| `rsi_period` | 14.0 | 13.75 ~ 14.0 | 14 (75%) | +| `rsi_oversold` | 18.5 | 18.0 ~ 18.5 | 18.5 (32%) | +| `rsi_overbought` | 70.0 | 68.375 ~ 72.75 | 69.0 (11%) | +| `sl_pct` | 1.65 | 1.2 ~ 2.3 | 1.5 (18%) | +| `tp_pct` | 2.5 | 2.1 ~ 3.225 | 2.3 (11%) | +| `tp_max_pct` | 6.5 | 6.0 ~ 7.5 | 6.0 (25%) | +| `drop_rate` | 8.55 | 7.9 ~ 8.825 | 10.0 (14%) | +| `shoulder_min_high` | 0.7 | 0.7 ~ 0.825 | 0.7 (39%) | +| `shoulder_cut_pct` | 0.15 | 0.1 ~ 0.25 | 0.15 (25%) | +| `cooldown_min` | 3.0 | 3.0 ~ 4.0 | 4 (39%) | +| `high_chase_thr` | 0.97 | 0.965 ~ 0.975 | 0.97 (29%) | +| `max_daily_chg` | 42.0 | 26.75 ~ 44.25 | 45.0 (14%) | +- _참고: 과적합%는 교차검증 점수가 아니라 표본·이상치·고원 휴리스틱입니다. 적용 가능도%=100−과적합위험%. DB 적용 버튼 활성 조건(gated PnL>0)과는 별개입니다._ + +## 이전 장에서는 +- 실매 동기간 청산 8건 · 승률 0% · 합산 PnL -32,761원 (백테 Optuna와 숫자 달라도 정상 — 고스트·유니버스·틱 경로 차이). +- 사후합격 1위: trial #130 · 거래 1건 · 승률 100.0% · PF 9999.00 · PnL 1,542원 +- 승률이 비정상적으로 높고 표본이 작습니다 → **해당 장에만 맞는 과적합** 신호. + +## 앞으로 장에서는 +- **1~2일 Optuna best를 바로 실매에 넣지 마세요.** 다일(≥5거래일) 재검증·웹백테 1회 후 검토. +- 적용 시에는 `results_gated` 후보만. 탐색용 best(objective)와 다를 수 있습니다. +- 장이 좁아지거나 변동성이 커지면: 손절·트레일·시간창이 먼저 깨집니다. 승률 자랑보다 **손절이 살아 있는지**를 보세요. +- 사후합격이 있어도 ‘내일도 같은 장’이 아닙니다. 페이퍼/소액·모의로 1~2일 관찰 후 확대하세요. + +_생성: 2026-08-21 12:21:56 (rule)_ + +## AI 코멘트 + +_(미생성: api_call_failed. 구글 키는 GEMINI_API_KEY 를 DB/환경에 넣으세요 — kis_trader/backtest/optuna_briefing.py 모듈 docstring 참고)_ diff --git a/kis_trader/backtest/results/optuna_tail_tpe_20260821_111355.briefing.md b/kis_trader/backtest/results/optuna_tail_tpe_20260821_111355.briefing.md new file mode 100644 index 0000000..a5b520b --- /dev/null +++ b/kis_trader/backtest/results/optuna_tail_tpe_20260821_111355.briefing.md @@ -0,0 +1,45 @@ +# Optuna 브리핑 — 꼬리 (tpe) +기간: 2026-08-18 ~ 2026-08-20 (거래일≈3일) · trials 200/200 +탐색 게이트: WR≥0% PF≥0 · min_trades≥1 | 후보 게이트: WR≥40% PF≥1 · min_trades≥1 +- min_trades=1: **합격 인정 최소 거래 건수**(표본 부족·우연 승률 방지). 이보다 적은 조합은 학습/후보 집계에서 제외됩니다. +학습용 통과 178건 · 사후합격(results_gated) 68건 + +## 과적합·적용 가능도 (휴리스틱) +- **과적합 위험 26.0%** · **적용 가능도 74.0%** · 판정: **상대적으로낮음** — 휴리스틱상 위험 상대적 낮음 — 그래도 다일 확인 권장 + - 표본 장일: +16.0p — 거래일≈3일 — 다일 재검증 권장(≥5일) + - 거래 표본: +10.0p — 상위 후보 거래 8건 — 해석 시 주의 + - 승률·PF 이상치: +0.0p — 이상치 없음 + - 게이트 커버: +0.0p — 사후합격 68건 · stable 56건 + - PnL 고원: +0.0p — 고원 비율 1% (1건) +- 임계값 분포 표본: results_gated 상위 30건 +| 파라미터 | median | p25~p75 | mode(비율) | +|---|---:|---|---| +| `entry_mode` | — | — | align (100%) | +| `cand_limit` | 20.0 | 0.0 ~ 20.0 | 20 (63%) | +| `max_daily_change` | 25.0 | 21.5 ~ 28.0 | 28.0 (13%) | +| `min_drop_rate` | 0.085 | 0.06 ~ 0.11375 | 0.085 (10%) | +| `min_recovery_ratio` | 0.17 | 0.1125 ~ 0.2 | 0.2 (23%) | +| `tail_ratio_min` | 0.7 | 0.5 ~ 0.8 | 0.7 (23%) | +| `tail_pct_min` | 0.00775 | 0.006125 ~ 0.009 | 0.0075 (13%) | +| `max_rec_3m` | 0.78 | 0.74 ~ 0.8375 | 0.73 (10%) | +| `shoulder_min_high` | 0.005 | 0.004 ~ 0.006 | 0.006 (27%) | +| `shoulder_cut_pct` | 0.002 | 0.001 ~ 0.0025 | 0.002 (23%) | +| `stop_atr_mult` | 1.4 | 1.1 ~ 1.5 | 1.5 (20%) | +| `target_atr_mult` | 2.35 | 1.925 ~ 2.6 | 2.6 (17%) | +- _참고: 과적합%는 교차검증 점수가 아니라 표본·이상치·고원 휴리스틱입니다. 적용 가능도%=100−과적합위험%. DB 적용 버튼 활성 조건(gated PnL>0)과는 별개입니다._ + +## 이전 장에서는 +- 실매 동기간 청산 1건 · 승률 0% · 합산 PnL 0원 (백테 Optuna와 숫자 달라도 정상 — 고스트·유니버스·틱 경로 차이). +- 사후합격 1위: trial #144 · 거래 8건 · 승률 87.5% · PF 10.31 · PnL 16,887원 + +## 앞으로 장에서는 +- **1~2일 Optuna best를 바로 실매에 넣지 마세요.** 다일(≥5거래일) 재검증·웹백테 1회 후 검토. +- 적용 시에는 `results_gated` 후보만. 탐색용 best(objective)와 다를 수 있습니다. +- 장이 좁아지거나 변동성이 커지면: 손절·트레일·시간창이 먼저 깨집니다. 승률 자랑보다 **손절이 살아 있는지**를 보세요. +- 사후합격이 있어도 ‘내일도 같은 장’이 아닙니다. 페이퍼/소액·모의로 1~2일 관찰 후 확대하세요. + +_생성: 2026-08-21 11:15:39 (rule)_ + +## AI 코멘트 + +_(미생성: api_call_failed. 구글 키는 GEMINI_API_KEY 를 DB/환경에 넣으세요 — kis_trader/backtest/optuna_briefing.py 모듈 docstring 참고)_ diff --git a/kis_trader/backtest/results/optuna_tail_tpe_20260821_113102.briefing.md b/kis_trader/backtest/results/optuna_tail_tpe_20260821_113102.briefing.md new file mode 100644 index 0000000..9e365c1 --- /dev/null +++ b/kis_trader/backtest/results/optuna_tail_tpe_20260821_113102.briefing.md @@ -0,0 +1,46 @@ +# Optuna 브리핑 — 꼬리 (tpe) +기간: 2026-08-18 ~ 2026-08-20 (거래일≈3일) · trials 200/200 +탐색 게이트: WR≥0% PF≥0 · min_trades≥1 | 후보 게이트: WR≥40% PF≥1 · min_trades≥1 +- min_trades=1: **합격 인정 최소 거래 건수**(표본 부족·우연 승률 방지). 이보다 적은 조합은 학습/후보 집계에서 제외됩니다. +학습용 통과 96건 · 사후합격(results_gated) 88건 + +## 과적합·적용 가능도 (휴리스틱) +- **과적합 위험 72.0%** · **적용 가능도 28.0%** · 판정: **비권장** — 과적합·표본부족 위험 높음 — 실매 DB 즉시 적용 비권장 + - 표본 장일: +16.0p — 거래일≈3일 — 다일 재검증 권장(≥5일) + - 거래 표본: +25.0p — 상위 후보 거래 1건 — 우연 승·과적합 가능 + - 승률·PF 이상치: +25.0p — 승률 100.0% + 거래 1건 · PF 9999.00 (소수 거래 폭증) + - 게이트 커버: +0.0p — 사후합격 88건 · stable 0건 + - PnL 고원: +6.0p — PnL 고원 22/88 (25%) +- 임계값 분포 표본: results_gated 상위 30건 +| 파라미터 | median | p25~p75 | mode(비율) | +|---|---:|---|---| +| `entry_mode` | — | — | limit_atr (100%) | +| `cand_limit` | 20.0 | 20.0 ~ 20.0 | 20 (80%) | +| `max_daily_change` | 32.0 | 24.25 ~ 39.0 | 40.0 (10%) | +| `min_drop_rate` | 0.0675 | 0.035 ~ 0.12375 | 0.15 (13%) | +| `min_recovery_ratio` | 0.15 | 0.1125 ~ 0.1975 | 0.12 (13%) | +| `tail_ratio_min` | 0.8 | 0.5 ~ 1.0 | 1.0 (23%) | +| `tail_pct_min` | 0.0025 | 0.001125 ~ 0.003875 | 0.001 (20%) | +| `max_rec_3m` | 0.755 | 0.7325 ~ 0.7975 | 0.76 (13%) | +| `shoulder_min_high` | 0.012 | 0.00825 ~ 0.013 | 0.013 (23%) | +| `shoulder_cut_pct` | 0.0015 | 0.001 ~ 0.002 | 0.001 (27%) | +| `stop_atr_mult` | 2.55 | 1.525 ~ 2.8 | 2.7 (17%) | +| `target_atr_mult` | 1.15 | 0.9 ~ 1.475 | 0.8 (20%) | +- _참고: 과적합%는 교차검증 점수가 아니라 표본·이상치·고원 휴리스틱입니다. 적용 가능도%=100−과적합위험%. DB 적용 버튼 활성 조건(gated PnL>0)과는 별개입니다._ + +## 이전 장에서는 +- 실매 동기간 청산 1건 · 승률 0% · 합산 PnL 0원 (백테 Optuna와 숫자 달라도 정상 — 고스트·유니버스·틱 경로 차이). +- 사후합격 1위: trial #34 · 거래 1건 · 승률 100.0% · PF 9999.00 · PnL 2,061원 +- 승률이 비정상적으로 높고 표본이 작습니다 → **해당 장에만 맞는 과적합** 신호. + +## 앞으로 장에서는 +- **1~2일 Optuna best를 바로 실매에 넣지 마세요.** 다일(≥5거래일) 재검증·웹백테 1회 후 검토. +- 적용 시에는 `results_gated` 후보만. 탐색용 best(objective)와 다를 수 있습니다. +- 장이 좁아지거나 변동성이 커지면: 손절·트레일·시간창이 먼저 깨집니다. 승률 자랑보다 **손절이 살아 있는지**를 보세요. +- 사후합격이 있어도 ‘내일도 같은 장’이 아닙니다. 페이퍼/소액·모의로 1~2일 관찰 후 확대하세요. + +_생성: 2026-08-21 11:31:32 (rule)_ + +## AI 코멘트 + +_(미생성: api_call_failed. 구글 키는 GEMINI_API_KEY 를 DB/환경에 넣으세요 — kis_trader/backtest/optuna_briefing.py 모듈 docstring 참고)_ diff --git a/kis_trader/backtest/scalping_backtest_common.py b/kis_trader/backtest/scalping_backtest_common.py index e10183d..972b387 100644 --- a/kis_trader/backtest/scalping_backtest_common.py +++ b/kis_trader/backtest/scalping_backtest_common.py @@ -173,21 +173,15 @@ def load_scalp_candles_by_code( ) return candles_by_code, total_candles - from kis_trader.backtest.bt_candle_source import ( - fetch_ws_candles_for_code, - list_ws_candle_codes, + from kis_trader.backtest.bt_candle_source import fetch_ws_candles_by_code_bulk + + loaded = fetch_ws_candles_by_code_bulk( + db, 1, start_key, end_key, + confirmed_only=True, ) - - codes = list_ws_candle_codes(db, 1, start_key, end_key) - candles_by_code: Dict[str, List[Dict]] = {} total_candles = 0 - - for code in codes: - rows = fetch_ws_candles_for_code( - db, code, 1, start_key, end_key, - confirmed_only=True, - ) + for code, rows in loaded.items(): if len(rows) < min_bars: continue candles_by_code[code] = rows diff --git a/kis_trader/backtest/tail_backtest_common.py b/kis_trader/backtest/tail_backtest_common.py index 0f0de67..3d04b57 100644 --- a/kis_trader/backtest/tail_backtest_common.py +++ b/kis_trader/backtest/tail_backtest_common.py @@ -346,27 +346,28 @@ def load_tail_candles_by_code( peak_sel = ", holding_peak" if has_holding_peak else "" ind_cols = ws_candles_select_indicator_cols(db) - from kis_trader.backtest.bt_candle_source import ( - fetch_ws_candles_for_code, - list_ws_candle_codes, - ) + from kis_trader.backtest.bt_candle_source import fetch_ws_candles_by_code_bulk - codes = list_ws_candle_codes(db, tail_tf, start_key, end_key) - - # 3분 합성 시 1분만 있는 종목도 후보에 포함 synth_on = ( tail_tf == 3 and get_env_bool("TAIL_BT_SYNTH_3M_FROM_1M", True) ) + loaded_tf = fetch_ws_candles_by_code_bulk( + db, tail_tf, start_key, end_key, + extra_select=ind_cols, + peak_sel=peak_sel, + confirmed_only=True, + ) + loaded_1m: Dict[str, List[Dict]] = {} if synth_on: - try: - codes_1m = list_ws_candle_codes(db, 1, start_key, end_key) - for c in codes_1m: - if c not in codes: - codes.append(c) - codes = sorted(set(codes)) - except Exception: - pass + loaded_1m = fetch_ws_candles_by_code_bulk( + db, 1, start_key, end_key, + extra_select=ind_cols, + peak_sel=peak_sel, + confirmed_only=True, + ) + + codes = sorted(set(list(loaded_tf.keys()) + list(loaded_1m.keys()))) candles_by_code: Dict[str, List[Dict]] = {} total_candles = 0 @@ -374,16 +375,11 @@ def load_tail_candles_by_code( synth_filled_total = 0 for code in codes: - bars = fetch_ws_candles_for_code( - db, code, tail_tf, start_key, end_key, - extra_select=ind_cols, - peak_sel=peak_sel, - confirmed_only=True, - ) - + bars = list(loaded_tf.get(code) or []) if synth_on: bars, n_fill = _synth_fill_3m_holes_from_1m( db, code, bars, start_key, end_key, peak_sel=peak_sel, + rows_1m=loaded_1m.get(code) or [], ) synth_filled_total += n_fill @@ -428,17 +424,19 @@ def _synth_fill_3m_holes_from_1m( end_key: str, *, peak_sel: str = "", + rows_1m: Optional[List[Dict]] = None, ) -> Tuple[List[Dict], int]: """DB 3분 리스트에 없는 시각만 1분→3분 롤업으로 보강.""" from kis_trader.engine.candle_rollup import merge_fill_holes, rollup_1m_bars_to_tf from kis_trader.backtest.bt_candle_source import fetch_ws_candles_for_code try: - rows_1m = fetch_ws_candles_for_code( - db, code, 1, start_key, end_key, - peak_sel=peak_sel, - confirmed_only=True, - ) + if rows_1m is None: + rows_1m = fetch_ws_candles_for_code( + db, code, 1, start_key, end_key, + peak_sel=peak_sel, + confirmed_only=True, + ) except Exception: return bars_3m, 0 if not rows_1m: diff --git a/kis_trader/backtest/trigger_snapshot_loader.py b/kis_trader/backtest/trigger_snapshot_loader.py index 5a5a794..693face 100644 --- a/kis_trader/backtest/trigger_snapshot_loader.py +++ b/kis_trader/backtest/trigger_snapshot_loader.py @@ -13,6 +13,7 @@ from kis_trader.engine.feed_fallback import ( live_feed_fallback_max_age_sec, orderbook_row_lag_seconds, ) +from kis_trader.utils.env import get_env_bool, get_env_from_db from kis_trader.ws.orderbook_cache import OrderbookSnapshot, orderbook_snapshot_from_storage from kis_trader.ws.program_cache import ProgramSnapshot, program_snapshot_from_storage from kis_trader.ws.trigger_eval_recorder import FILTER_EVAL_SOURCE @@ -661,6 +662,13 @@ def load_trigger_snapshots_by_code( meta["log_verdict_rows"] = sum( len(lst) for cm in log_verdict.values() for lst in cm.values() ) + # 옵투나/백테: 호가 스냅 벤더 비율 (kiwoom_0d / ls_uh1 / log_backfill …) + try: + from kis_trader.backtest.optuna_feed_trace import log_bt_orderbook_feed_trace + + log_bt_orderbook_feed_trace(ob_by_code, meta, context="호가로드") + except Exception as e: + logger.debug("호가 피드추적 로그 스킵: %s", e) return ob_by_code, pg_by_code, meta diff --git a/kis_trader/engine/feed_fallback.py b/kis_trader/engine/feed_fallback.py index 7766dcb..90cf200 100644 --- a/kis_trader/engine/feed_fallback.py +++ b/kis_trader/engine/feed_fallback.py @@ -8,6 +8,7 @@ kis_trader/engine/feed_fallback.py — 실매·옵투나 공통 읽기 나이 from __future__ import annotations from datetime import datetime +import time from typing import Any, Dict, List, Optional, Sequence, Tuple from kis_trader.utils.env import get_env_bool, get_env_float, get_env_from_db @@ -131,6 +132,177 @@ def format_mm_feed_line(tick_lab: str, ob_lab: str) -> str: return f"시세: {t} | 호가: {o}" +def trigger_feed_detail_log_enabled() -> bool: + """트리거(매수체크) 로그에 틱·호가 상세 추적 줄. 기본 ON.""" + try: + return bool(get_env_bool("TRIGGER_FEED_DETAIL_LOG", True)) + except Exception: + return True + + +def extract_tick_trace_fields(data: Optional[Dict[str, Any]]) -> Dict[str, Any]: + """get_price dict → 로그용 현재가·틱타임·나이.""" + out: Dict[str, Any] = {} + if not isinstance(data, dict): + return out + px = ( + data.get("stck_prpr") + or data.get("price") + or data.get("cur_prc") + or data.get("close") + or 0 + ) + try: + out["price"] = float(str(px).replace(",", "") or 0) + except (TypeError, ValueError): + out["price"] = 0.0 + tt = ( + data.get("tick_time") + or data.get("tick_time_raw") + or data.get("chetime") + or data.get("cntr_tm") + or data.get("FID20") + or data.get("stck_cntg_hour") + or data.get("cntg_hour") + or "" + ) + out["tick_time"] = str(tt or "").strip() + if not out["tick_time"]: + bs = str(data.get("kis_bsop_date_raw") or "").strip() + hr = str( + data.get("kis_cntg_hour_raw") + or data.get("chetime") + or data.get("kiwoom_fid20") + or "" + ).strip() + if bs and hr: + out["tick_time"] = (bs + hr)[:14] + elif hr: + out["tick_time"] = hr + # 키움 캐시에 tick_time 키가 없어도 chetime만으로 lag 계산 + if data.get("chetime") and not out.get("tick_time"): + out["tick_time"] = str(data.get("chetime") or "").strip() + if data.get("_age_ms") is not None: + try: + out["age_ms"] = int(data.get("_age_ms") or 0) + except (TypeError, ValueError): + pass + lag = packet_lag_seconds(out.get("tick_time") or "") + if lag is not None: + out["lag_sec"] = round(float(lag), 2) + if data.get("_feed_vendor"): + out["vendor"] = str(data.get("_feed_vendor") or "").strip().lower() + return out + + +def extract_ob_trace_fields(snap: Any) -> Dict[str, Any]: + """호가 스냅샷 → bid/ask/스프레드/잔량비·스냅시각.""" + out: Dict[str, Any] = {} + if snap is None: + return out + try: + if hasattr(snap, "best_bid"): + out["best_bid"] = int(snap.best_bid() or 0) + out["best_ask"] = int(snap.best_ask() or 0) + try: + out["spread_pct"] = round(float(snap.spread_pct() or 0), 3) + except Exception: + pass + try: + bq = int(getattr(snap, "total_bid_qty", 0) or 0) + aq = int(getattr(snap, "total_ask_qty", 0) or 0) + out["bid_qty"] = bq + out["ask_qty"] = aq + if aq > 0: + out["bid_ask_ratio"] = round(bq / float(aq), 3) + except Exception: + pass + out["snap_time"] = str(getattr(snap, "snap_time", "") or "").strip() + src = str(getattr(snap, "source", "") or "").strip() + if src: + out["ob_source"] = src + if getattr(snap, "ts", None): + try: + out["age_ms"] = int(max(0.0, (time.time() - float(snap.ts)) * 1000)) + except Exception: + pass + return out + except Exception: + pass + if isinstance(snap, dict): + try: + out["best_bid"] = int(float(snap.get("best_bid") or snap.get("bidp1") or 0)) + out["best_ask"] = int(float(snap.get("best_ask") or snap.get("askp1") or 0)) + except (TypeError, ValueError): + pass + out["snap_time"] = str(snap.get("snap_time") or "").strip() + return out + + +def format_trigger_feed_trace( + tick_rec: Optional[Dict[str, Any]], + ob_rec: Optional[Dict[str, Any]], + *, + tick_primary: str = "", + ob_primary: str = "", +) -> str: + """매수체크 로그 꼬리 — 벤더(1·2·3차) + 현재가 + 틱타임 + 호가.""" + tp = str(tick_primary or live_tick_primary()).strip().lower() or "kiwoom" + op = str(ob_primary or live_ob_primary()).strip().lower() or "kiwoom" + alt_t = "kiwoom" if tp == "kis" else "kis" + alt_o = "kiwoom" if op == "kis" else "kis" + parts: List[str] = [ + f"틱1차설정={tp}(체인 {tp}→{alt_t}→ls)", + f"호가1차설정={op}(체인 {op}→{alt_o}→ls)", + ] + tr = tick_rec or {} + if tr.get("label") or tr.get("vendor"): + lab = str(tr.get("label") or format_vendor_label( + str(tr.get("vendor") or ""), + int(tr.get("tier") or tick_feed_tier(str(tr.get("vendor") or ""), tp)), + bool(tr.get("spilled")), + )) + bit = [f"틱실제={lab}"] + if tr.get("price"): + try: + bit.append("px=%s" % int(float(tr.get("price") or 0))) + except (TypeError, ValueError): + bit.append("px=%s" % tr.get("price")) + if tr.get("tick_time"): + bit.append("t=%s" % tr.get("tick_time")) + if tr.get("lag_sec") is not None: + bit.append("lag=%ss" % tr.get("lag_sec")) + elif tr.get("age_ms") is not None: + bit.append("age=%sms" % tr.get("age_ms")) + parts.append(" ".join(bit)) + else: + parts.append("틱실제=없음") + obr = ob_rec or {} + if obr.get("label") or obr.get("vendor"): + lab = str(obr.get("label") or format_vendor_label( + str(obr.get("vendor") or ""), + int(obr.get("tier") or tick_feed_tier(str(obr.get("vendor") or ""), op)), + bool(obr.get("spilled")), + )) + bit = [f"호가실제={lab}"] + if obr.get("best_bid") or obr.get("best_ask"): + bit.append("bid=%s ask=%s" % (obr.get("best_bid") or 0, obr.get("best_ask") or 0)) + if obr.get("spread_pct") is not None: + bit.append("spr=%s%%" % obr.get("spread_pct")) + if obr.get("bid_ask_ratio") is not None: + bit.append("or=%s" % obr.get("bid_ask_ratio")) + if obr.get("snap_time"): + bit.append("snap=%s" % obr.get("snap_time")) + if obr.get("ob_source"): + bit.append("src=%s" % obr.get("ob_source")) + if obr.get("age_ms") is not None: + bit.append("age=%sms" % obr.get("age_ms")) + parts.append(" ".join(bit)) + else: + parts.append("호가실제=없음") + return " | ".join(parts) + + def _tick_second_key(tick: Dict[str, Any]) -> str: tt = str(tick.get("tick_time") or "")[:14] if len(tt) >= 14: diff --git a/kis_trader/engine/live_sell_price.py b/kis_trader/engine/live_sell_price.py index e897560..bb9a8ce 100644 --- a/kis_trader/engine/live_sell_price.py +++ b/kis_trader/engine/live_sell_price.py @@ -18,6 +18,31 @@ from kis_trader.utils.env import get_env_float _stale_rest_lock = threading.Lock() _stale_rest_ts: dict = {} _stale_log_ts: dict = {} +# WS/REST 성공 직후 가격 — REST 쿨다운(기본 30s) 동안 재사용 (매도 공백 방지) +_last_good_lock = threading.Lock() +_last_good_px: dict = {} + + +def _remember_last_good(code: str, px: float, src: str) -> None: + code = str(code or "").strip() + if not code or px <= 0: + return + with _last_good_lock: + _last_good_px[code] = (float(px), str(src or "WS"), time.time()) + + +def _last_good_price(code: str, max_age_sec: float) -> Tuple[float, str]: + code = str(code or "").strip() + if not code or max_age_sec <= 0: + return 0.0, "" + with _last_good_lock: + rec = _last_good_px.get(code) + if not rec: + return 0.0, "" + px, src, ts = rec + if (time.time() - float(ts)) > float(max_age_sec): + return 0.0, "" + return float(px), str(src or "") def _stck_prpr(raw: Any) -> float: @@ -63,6 +88,43 @@ def _ws_chain_price(ws: Any, code: str) -> Tuple[float, str]: return 0.0, "" +def _quote_packet_raw(wsd: Any) -> str: + """체결시각 원문. FID20 동결 RAM 을 매도 last-RAM 으로 쓰지 않기 위함.""" + if not isinstance(wsd, dict): + return "" + for k in ( + "kis_cntg_hour_raw", "chetime", "tick_time", "kiwoom_fid20", + "stck_cntg_hour", + ): + v = str(wsd.get(k) or "").strip() + if v: + return v + return "" + + +def _last_ram_sell_ok(wsd: Any, max_age_sec: float) -> bool: + """마지막 RAM: wall 나이·패킷 나이 둘 다 max 이내. 동결 FID20 거부.""" + if max_age_sec <= 0 or not isinstance(wsd, dict): + return False + try: + wall = float(wsd.get("_age_ms") or 0) / 1000.0 + except (TypeError, ValueError): + wall = 0.0 + if wall > float(max_age_sec): + return False + raw = _quote_packet_raw(wsd) + if not raw: + return True + try: + from kis_trader.engine.feed_fallback import packet_lag_seconds + lag = packet_lag_seconds(raw) + except Exception: + return True + if lag is None: + return True + return float(lag) <= float(max_age_sec) + + def _ws_last_and_age(ws: Any, code: str) -> Tuple[float, float]: """마지막 RAM 체결가 + 나이(초). 캐시 없으면 (0, inf). EOD 전용.""" getter = getattr(ws, "get_price", None) @@ -153,6 +215,7 @@ def resolve_live_sell_price( if is_eod: ws_px, _age = _ws_last_and_age(ws, code) if ws_px > 0: + _remember_last_good(code, ws_px, "WS") return ws_px, "WS" if stale_sec > 0 and _stale_rest_allowed(code, cooldown): rest_px = _kiwoom_rest_once(ws, code) @@ -171,11 +234,52 @@ def resolve_live_sell_price( chain_px, vendor = _ws_chain_price(ws, code) if chain_px > 0: + _remember_last_good(code, chain_px, vendor or "WS") return chain_px, vendor or "WS" + last_max = float(get_env_float("SELL_WS_LAST_RAM_MAX_AGE_SEC", 30.0) or 0.0) + if last_max > 0: + last_px, last_age = _ws_last_and_age(ws, code) + getter = getattr(ws, "get_price", None) + wsd_last = None + if callable(getter): + try: + wsd_last = getter(code, max_age_sec=None) + except TypeError: + wsd_last = None + except Exception: + wsd_last = None + if last_px > 0 and _last_ram_sell_ok(wsd_last, last_max): + lab = "" + if isinstance(wsd_last, dict): + lab = str(wsd_last.get("_feed_vendor") or "").strip() + _remember_last_good(code, last_px, lab or "WS_last") + _log_throttled( + logger, + "last_ram:" + code, + "📌 [매도시세] %s 3초체인 실패 → last-RAM %.0f (age=%.1fs)", + code, + last_px, + last_age if last_age != float("inf") else -1.0, + ) + return last_px, "WS_last" + + cached_px, cached_src = _last_good_price(code, cooldown) + if cached_px > 0: + _log_throttled( + logger, + "last_good:" + code, + "📌 [매도시세] %s WS체인 실패 → 직전가 유지(%s) %.0f", + code, + cached_src, + cached_px, + ) + return cached_px, cached_src + "_cached" + if stale_sec > 0 and _stale_rest_allowed(code, cooldown): rest_px = _kiwoom_rest_once(ws, code) if rest_px > 0: + _remember_last_good(code, rest_px, "kiwoom_rest") _log_throttled( logger, "stale_rest:" + code, diff --git a/kis_trader/engine/momentum_tick_replay.py b/kis_trader/engine/momentum_tick_replay.py index 48142f8..6245a18 100644 --- a/kis_trader/engine/momentum_tick_replay.py +++ b/kis_trader/engine/momentum_tick_replay.py @@ -273,7 +273,8 @@ def align_momentum_entry_from_ticks( 진입봉 첫 유효 틱 체결가 (없으면 분봉 시가). Returns: - (fill_price, entry_time_key, source) — source: ws_ticks | ohlc_open + (fill_price, entry_time_key, source) — source: ws_ticks: | ohlc_open + (vendor=kis|kiwoom|ls … — 옵투나/백테 피드 추적용) """ p = params or {} fo = float(fallback_open or 0) @@ -293,6 +294,7 @@ def align_momentum_entry_from_ticks( owner = ticks.owner _price = owner._price _tick_time = owner._tick_time + _source = getattr(owner, "_source", None) for i in ticks.iter_idx(): tt = _tick_time[i].decode("utf-8") if min_tt and len(tt) >= 14 and tt[:14] < min_tt[:14]: @@ -302,7 +304,13 @@ def align_momentum_entry_from_ticks( continue fill_px = px * (1.0 + slip_pct / 100.0) if slip_pct > 0 else px et = tt[:14] if len(tt) >= 14 else bar_key - return fill_px, et, "ws_ticks" + vendor = "" + if _source is not None: + try: + vendor = _source[i].decode("utf-8").strip().lower() + except Exception: + vendor = "" + return fill_px, et, (f"ws_ticks:{vendor}" if vendor else "ws_ticks") return fo, bar_key, "ohlc_open" for tick in ticks: tt = str(tick.get("tick_time") or "") @@ -314,7 +322,12 @@ def align_momentum_entry_from_ticks( fill_px = px * (1.0 + slip_pct / 100.0) if slip_pct > 0 else px tt = str(tick.get("tick_time") or "") et = tt[:14] if len(tt) >= 14 else bar_key - return fill_px, et, "ws_ticks" + try: + from kis_trader.backtest.optuna_feed_trace import tick_source_label + + return fill_px, et, tick_source_label(tick) + except Exception: + return fill_px, et, "ws_ticks" return fo, bar_key, "ohlc_open" diff --git a/kis_trader/engine/tail_tick_replay.py b/kis_trader/engine/tail_tick_replay.py index df01d10..4a74613 100644 --- a/kis_trader/engine/tail_tick_replay.py +++ b/kis_trader/engine/tail_tick_replay.py @@ -144,7 +144,10 @@ def align_entry_price_from_ticks( ticks: List[Dict[str, Any]], fallback_open: float, ) -> Tuple[float, str]: - """신호 직후(진입봉) 첫 체결 틱 가격 (없으면 시가).""" + """신호 직후(진입봉) 첫 체결 틱 가격 (없으면 시가). + + Returns source: ``ws_ticks:`` | ``ohlc_open`` (옵투나 피드 추적용). + """ fo = float(fallback_open or 0) if not ticks: return fo, "ohlc_open" @@ -152,18 +155,31 @@ def align_entry_price_from_ticks( try: from kis_trader.backtest.shared_ticks import TickColumnView if isinstance(ticks, TickColumnView): - _price = ticks.owner._price + owner = ticks.owner + _price = owner._price + _source = getattr(owner, "_source", None) for i in ticks.iter_idx(): price = float(_price[i]) if price > 0: - return price, "ws_ticks" + vendor = "" + if _source is not None: + try: + vendor = _source[i].decode("utf-8").strip().lower() + except Exception: + vendor = "" + return price, (f"ws_ticks:{vendor}" if vendor else "ws_ticks") return fo, "ohlc_open" except Exception: pass for tick in ticks: price = float(tick.get("price") or 0) if price > 0: - return price, "ws_ticks" + try: + from kis_trader.backtest.optuna_feed_trace import tick_source_label + + return price, tick_source_label(tick) + except Exception: + return price, "ws_ticks" return fo, "ohlc_open" diff --git a/kis_trader/execution/order_manager.py b/kis_trader/execution/order_manager.py index be71837..043a8cb 100644 --- a/kis_trader/execution/order_manager.py +++ b/kis_trader/execution/order_manager.py @@ -812,46 +812,66 @@ class OrderManager: ord_no, strategy_id=req.strategy_id, code=req.code, ) prev_filled = int((existing or {}).get("filled_qty") or 0) + # orders.filled 만 먼저 찍히고 close_trade 가 빠진 경우 + # (모의 가정체결·지정가 merge 선반영) → active 남으면 장부 보완. + # 진짜 중복 poll 은 active 도 0 이라 여기서 스킵. + repair_only = False if filled_qty <= prev_filled: - logger.info( - "%s⏭ [매도체결] 이미 반영 스킵 ODNO=%s filled=%d <= db=%d%s", - LOG_CYAN, ord_no, filled_qty, prev_filled, LOG_RESET, + remain = self._active_qty(req.strategy_id, req.code) + if remain <= 0: + logger.info( + "%s⏭ [매도체결] 이미 반영 스킵 ODNO=%s filled=%d <= db=%d%s", + LOG_CYAN, ord_no, filled_qty, prev_filled, LOG_RESET, + ) + prev_px = float( + (existing or {}).get("filled_avg_price") or sell_price or 0 + ) + return OrderResult( + True, + ord_no=ord_no, + filled_qty=prev_filled, + filled_avg_price=prev_px, + reason="sell_fill_already_applied", + request=req, + ) + repair_only = True + delta_qty = remain + if sell_price <= 0: + sell_price = float( + (existing or {}).get("filled_avg_price") or 0 + ) + logger.warning( + "%s⚠️ [매도체결·장부보완] [%s] %s %s ODNO=%s " + "orders.filled=%d 이미 있으나 active=%d → close_trade%s", + LOG_YELLOW, req.strategy_id, req.name, req.code, ord_no, + prev_filled, remain, LOG_RESET, ) - prev_px = float( - (existing or {}).get("filled_avg_price") or sell_price or 0 - ) - return OrderResult( - True, + else: + delta_qty = filled_qty - prev_filled + + if not repair_only: + status = "FILLED" if filled_qty >= order_qty else "PARTIAL" + self.db.update_order_fill( ord_no=ord_no, - filled_qty=prev_filled, - filled_avg_price=prev_px, - reason="sell_fill_already_applied", - request=req, + strategy_id=req.strategy_id, + code=req.code, + filled_qty=filled_qty, + filled_avg_price=sell_price, + status=status, ) - delta_qty = filled_qty - prev_filled - status = "FILLED" if filled_qty >= order_qty else "PARTIAL" - self.db.update_order_fill( - ord_no=ord_no, - strategy_id=req.strategy_id, - code=req.code, - filled_qty=filled_qty, - filled_avg_price=sell_price, - status=status, - ) - - # 시장가 부분체결: IOC면 잔량 이미 취소, 아니면 잔량 취소 후 pending 해제 - if 0 < filled_qty < order_qty: - miss = order_qty - filled_qty - if not getattr(self.client, "uses_market_sell_ioc", lambda: False)(): - try: - self.client.cancel_order(ord_no, qty=miss) - except Exception as e: - logger.debug( - "매도 잔량 취소 스킵/실패 %s ODNO=%s: %s", - req.code, ord_no, e, - ) - self._seal_partial_sell_order(ord_no, req, filled_qty) + # 시장가 부분체결: IOC면 잔량 이미 취소, 아니면 잔량 취소 후 pending 해제 + if 0 < filled_qty < order_qty: + miss = order_qty - filled_qty + if not getattr(self.client, "uses_market_sell_ioc", lambda: False)(): + try: + self.client.cancel_order(ord_no, qty=miss) + except Exception as e: + logger.debug( + "매도 잔량 취소 스킵/실패 %s ODNO=%s: %s", + req.code, ord_no, e, + ) + self._seal_partial_sell_order(ord_no, req, filled_qty) fee_rate = float(get_env_from_db("FEE_RATE_PCT", "0.015")) / 100.0 tax_rate = float(get_env_from_db("SELL_TAX_RATE_PCT", "0.18")) / 100.0 @@ -2513,14 +2533,11 @@ class OrderManager: False, ord_no=ord_no, reason="sell_fill_pending", request=req, ) else: - # 레거시 모의: 참고가로 가정 체결 + # 레거시 모의: 참고가로 가정 체결. + # orders.filled 는 _finalize_sell_fill 에서만 기록 + # (여기서 선반영하면 filled<=db 스킵 → close_trade 누락 → ghost). sell_price = req.price_ref or req.buy_price filled_qty = sell_qty - self.db.update_order_fill( - ord_no=ord_no, strategy_id=req.strategy_id, code=req.code, - filled_qty=filled_qty, - filled_avg_price=sell_price, status="SUBMITTED", - ) # 익절 지정가 미확인·부분체결 → 취소 후 재조회, 필요할 때만 시장가 # (지정가 체결 중 시장가 재시도 → 타전략 몫까지 이중매도 방지) @@ -2541,14 +2558,7 @@ class OrderManager: filled_qty = int(merged["filled_qty"]) sell_price = float(merged["avg_price"]) fill = {"filled_qty": filled_qty, "avg_price": sell_price} - self.db.update_order_fill( - ord_no=ord_no, - strategy_id=req.strategy_id, - code=req.code, - filled_qty=filled_qty, - filled_avg_price=sell_price, - status="FILLED" if filled_qty >= sell_qty else "PARTIAL", - ) + # filled 선반영 금지 — finalize 가 update+close 원자적으로 처리 if pending or filled_qty <= 0 or sell_price <= 0: self.db.update_order_status( ord_no=ord_no, strategy_id=req.strategy_id, code=req.code, diff --git a/kis_trader/network/ws_manager.py b/kis_trader/network/ws_manager.py index 67ee41c..94f83e6 100644 --- a/kis_trader/network/ws_manager.py +++ b/kis_trader/network/ws_manager.py @@ -129,6 +129,8 @@ class WSManager: self._permanent_reload_ts: float = 0.0 # 후보/보유 이탈 후 키움 틱 구독 유지 (만료 epoch). 한투 41·영구(LS)와 별개. self._grace_until: Dict[str, float] = {} + # 보유 이탈 후 KIS 41슬롯 유지 (만료 epoch). 후보는 안 넣음. + self._kis_hold_grace_until: Dict[str, float] = {} # grace 1회 소진 후 재연장 방지 (재진입 시 discard) self._grace_exhausted: Set[str] = set() self._lock = threading.Lock() @@ -411,11 +413,21 @@ class WSManager: """ cand = {str(c).strip() for c in candidates if c} hold = {str(h).strip() for h in holdings if h} + left_hold: Set[str] = set() with self._lock: + prev_hold = set(self._owner_holdings.get(owner) or set()) + left_hold = prev_hold - hold if ls_feed: self._ls_feed_owners[owner] = set(cand | hold) else: self._ls_feed_owners.pop(owner, None) + # MINIMAL OFF 레거시 sync 에도 보유 pin 이 필요 + self._owner_candidates[owner] = cand + self._owner_holdings[owner] = hold + for code in left_hold: + if code in cand or code in hold: + continue + self._arm_kis_hold_grace(code) if not self._split_feed_active: if ls_feed: self._reconcile_ls_feed_subscriptions() @@ -901,6 +913,7 @@ class WSManager: def _remember_feed_read( self, kind: str, code: str, vendor: str, *, spilled: bool = False, + meta: Optional[dict] = None, ) -> None: code = (code or "").strip() v = self._normalize_feed_vendor(vendor, vendor or "") @@ -929,6 +942,13 @@ class WSManager: } except Exception: rec = {"vendor": v, "tier": 0, "spilled": bool(spilled), "label": v, "ts": time.time()} + if isinstance(meta, dict): + for k, val in meta.items(): + if k in ("vendor", "tier", "spilled", "label", "ts"): + continue + if val is None or val == "": + continue + rec[k] = val with self._feed_read_lock: if kind == "ob": self._last_ob_read[code] = rec @@ -978,6 +998,31 @@ class WSManager: rec = dict(self._last_ob_read.get(code) or {}) return self._label_from_read(rec, kind="ob", code=code) + def get_trigger_feed_trace(self, code: str) -> str: + """매수체크 로그용 — 1차설정·실제 벤더·현재가·틱타임·호가 한 줄.""" + code = (code or "").strip() + if not code: + return "" + try: + from kis_trader.engine.feed_fallback import ( + format_trigger_feed_trace, + live_ob_primary, + live_tick_primary, + trigger_feed_detail_log_enabled, + ) + if not trigger_feed_detail_log_enabled(): + return "" + with self._feed_read_lock: + tick = dict(self._last_tick_read.get(code) or {}) + ob = dict(self._last_ob_read.get(code) or {}) + return format_trigger_feed_trace( + tick, ob, + tick_primary=live_tick_primary(), + ob_primary=live_ob_primary(), + ) + except Exception: + return "" + def _vendor_price(self, vendor: str, code: str, max_age_sec: Optional[float]): v = self._normalize_feed_vendor(vendor, "") try: @@ -1097,6 +1142,7 @@ class WSManager: hold_u_kw = hold_u - pure_ls # 영구구독은 LS WS로 이관 → KIS/키움 슬롯에서 제외 kis_want = hold_u_kw - perm + kis_want |= self._purge_kis_hold_grace() kw_want = (cand_u_kw | hold_u_kw) - perm tick_to_agg = set(cand_u_kw - hold_u_kw) - perm # 갭 보정은 후보·보유만 (영구구독은 ls_ws_candles 탭 버튼) @@ -1181,7 +1227,17 @@ class WSManager: gap_mode = self._candidate_gap_fill_mode(code, owner_cands) self._enqueue_gap_fill(code, priority=True, mode=gap_mode) - for code in sorted(kis_want - kis_now): + to_kis = sorted(kis_want - kis_now) + hold_first = [ + c for c in to_kis + if c in hold_u_kw or self._kis_hold_protected(c) + ] + kis_rest = [c for c in to_kis if c not in set(hold_first)] + if hold_first: + self._ensure_kis_headroom_for_holdings( + len(hold_first), self._kis_keep_set(kis_want), + ) + for code in hold_first + kis_rest: home = self._subscribe_tick_prefer_or_spill(code, "kis") if home: self._enqueue_gap_fill(code) @@ -1193,7 +1249,10 @@ class WSManager: self._subscribe_ob_prefer_or_spill(code, "kis") for code in sorted(kis_now - kis_want): - # 한투는 grace 없음(MINIMAL에선 보유만) — want 밖이면 즉시 해제 + # 보유·보유grace(180s)는 KIS 41에서 안 뺌 + if self._kis_hold_protected(code): + continue + # 한투는 후보 grace 없음 — want 밖이면 즉시 해제 self.ws_cache.unsubscribe(code) if self.kis_ws_ob is not None and self.kis_ws_ob is not self.ws_cache: try: @@ -1242,6 +1301,86 @@ class WSManager: def _ws_grace_headroom(self) -> int: return max(0, get_env_int("WS_TICK_GRACE_HEADROOM", 5)) + def _all_holdings(self) -> Set[str]: + with self._lock: + out: Set[str] = set() + for s in self._owner_holdings.values(): + out |= set(s) + return {str(c).strip() for c in out if str(c).strip()} + + def _purge_kis_hold_grace(self) -> Set[str]: + now = time.time() + with self._lock: + dead = [c for c, exp in self._kis_hold_grace_until.items() if exp <= now] + for c in dead: + self._kis_hold_grace_until.pop(c, None) + return set(self._kis_hold_grace_until.keys()) + + def _kis_hold_protected(self, code: str) -> bool: + code = (code or "").strip() + if not code: + return False + if code in self._all_holdings(): + return True + self._purge_kis_hold_grace() + with self._lock: + return code in self._kis_hold_grace_until + + def _arm_kis_hold_grace(self, code: str) -> bool: + """보유에서 막 빠진 종목만 KIS 180초 유지. 후보는 대상 아님.""" + grace = self._ws_grace_sec() + code = (code or "").strip() + if grace <= 0 or not code: + return False + if code in self._all_holdings(): + return True + with self._lock: + if code in self._permanent_codes: + return False + if code in self._kis_hold_grace_until: + return True + self._kis_hold_grace_until[code] = time.time() + float(grace) + logger.info("📡 [KIS hold-grace] %s %ds (보유 이탈·41슬롯 유지)", code, int(grace)) + return True + + def _kis_keep_set(self, extra: Optional[Set[str]] = None) -> Set[str]: + keep = self._all_holdings() | self._purge_kis_hold_grace() + with self._lock: + keep |= set(self._permanent_codes) + if extra: + keep |= {str(c).strip() for c in extra if str(c).strip()} + return keep + + def _ensure_kis_headroom_for_holdings(self, need: int, keep: Set[str]) -> None: + """KIS 41 꽉 차면 후보(비보호)부터 해제 — 보유 pin 우선.""" + if need <= 0 or self.ws_cache is None: + return + try: + limit = int(getattr(self.ws_cache, "MAX_SUBSCRIPTIONS", 41) or 41) + except Exception: + limit = 41 + with self.ws_cache._sub_lock: + n_now = len(self.ws_cache._subscribed) + extra = [c for c in list(self.ws_cache._subscribed) if c not in keep] + free = limit - n_now + if free >= need: + return + drop_n = need - max(0, free) + dropped = 0 + for code in extra: + if dropped >= drop_n: + break + try: + self.ws_cache.unsubscribe(code) + except Exception: + pass + dropped += 1 + if dropped: + logger.info( + "📡 [KIS evict] 후보 %s 해제 (보유 pin headroom %d)", + code, drop_n, + ) + def _purge_and_get_grace_codes(self) -> Set[str]: """만료 grace 제거 후 활성 코드 집합 반환.""" now = time.time() @@ -1420,6 +1559,16 @@ class WSManager: is_permanent = code in self._permanent_codes if not still_refs and not is_permanent: + if self._kis_hold_protected(code): + # 보유·KIS 180s grace: 한투 틱 구독은 유지 + if self._kiwoom_ws and not self._split_feed_active: + if not self._note_leave_for_grace(code): + try: + self._kiwoom_ws.unsubscribe(code) + except Exception: + pass + self._sync_tick_record_codes() + return if self.ws_cache: self.ws_cache.unsubscribe(code) if self._kiwoom_ws and not self._split_feed_active: @@ -1454,10 +1603,17 @@ class WSManager: new_set = {c for c in codes if c} with self._lock: cur = set(self._owner_codes.get(owner, set())) - + hold_u = self._all_holdings() + added = sorted(new_set - cur) + added_hold = [c for c in added if c in hold_u] + added_rest = [c for c in added if c not in hold_u] + if added_hold: + self._ensure_kis_headroom_for_holdings( + len(added_hold), self._kis_keep_set(new_set), + ) for code in sorted(cur - new_set): self.unsubscribe(code, owner) - for code in sorted(new_set - cur): + for code in added_hold + added_rest: self.subscribe(code, owner) self._sync_tick_record_codes() self._sync_feed_fallback_to_ls(self._ls_ram_universe_codes()) @@ -1520,8 +1676,13 @@ class WSManager: if isinstance(p, dict): p = dict(p) p["_feed_vendor"] = v + try: + from kis_trader.engine.feed_fallback import extract_tick_trace_fields + meta = extract_tick_trace_fields(p if isinstance(p, dict) else None) + except Exception: + meta = None self._remember_feed_read( - "tick", code, v, spilled=bool(spilled and v == home), + "tick", code, v, spilled=bool(spilled and v == home), meta=meta, ) return p return None @@ -1602,8 +1763,13 @@ class WSManager: setattr(snap, "_feed_vendor", v) except Exception: pass + try: + from kis_trader.engine.feed_fallback import extract_ob_trace_fields + meta = extract_ob_trace_fields(snap) + except Exception: + meta = None self._remember_feed_read( - "ob", code, v, spilled=bool(spilled and v == home), + "ob", code, v, spilled=bool(spilled and v == home), meta=meta, ) return snap return None diff --git a/kis_trader/strategies/base.py b/kis_trader/strategies/base.py index cf07d44..3048fa3 100644 --- a/kis_trader/strategies/base.py +++ b/kis_trader/strategies/base.py @@ -40,20 +40,44 @@ def _live_feed_providers() -> Tuple[str, str]: class FeedPrefixLoggerAdapter(logging.LoggerAdapter): - """탈락/스캔 로그(🔍 [) 앞에 T:시세|O:호가 provider 접두어를 붙인다. + """탈락/스캔 로그(🔍 [) 앞에 T:설정|O:설정|R:실제읽기 접두어를 붙인다. - 예: 🔍 [탈락-RSI] → 🔍 [T:kiwoom|O:kiwoom|탈락-RSI] - (피드 출처 디버깅용 · 매매 수치 아님) + 예: 🔍 [탈락-RSI] → 🔍 [T:kis|O:kiwoom|R:kis(1차)|탈락-RSI] + T/O = LIVE_*_PROVIDER(DB). R = get_tick_feed_label(code) — 직전 get_price 읽기. + extra={'scan_code': code} 로 R: 활성화 (_scan_log 헬퍼 사용). + TRIGGER_FEED_DETAIL_LOG(기본 true) 이면 틱가·틱타임·호가·1/2/3차 체인 꼬리 추가. """ def process(self, msg, kwargs): if isinstance(msg, str) and "🔍 [" in msg and "시세:" not in msg and "LIVE_TICK_PROVIDER" in self.extra: try: # 이미 T:|O: 접두가 있으면 중복 삽입 금지 (매수체크 로그 등) - if "🔍 [T:" not in msg[:24]: + if "🔍 [T:" not in msg[:32]: tick_p, ob_p = _live_feed_providers() - if tick_p or ob_p: + read_lab = "" + extra = kwargs.get("extra") or {} + scan_code = str(extra.get("scan_code") or "").strip() + ws = self.extra.get("ws") + if scan_code and ws is not None and hasattr(ws, "get_tick_feed_label"): + try: + read_lab = str(ws.get_tick_feed_label(scan_code) or "").strip() + except Exception: + read_lab = "" + if read_lab: + msg = msg.replace( + "🔍 [", + f"🔍 [T:{tick_p}|O:{ob_p}|R:{read_lab}|", + 1, + ) + elif tick_p or ob_p: msg = msg.replace("🔍 [", f"🔍 [T:{tick_p}|O:{ob_p}|", 1) + if scan_code and ws is not None and hasattr(ws, "get_trigger_feed_trace"): + try: + detail = str(ws.get_trigger_feed_trace(scan_code) or "").strip() + except Exception: + detail = "" + if detail and "틱1차설정=" not in msg: + msg = f"{msg} · {detail}" except Exception: pass return msg, kwargs @@ -124,7 +148,10 @@ class BaseStrategy(ABC, threading.Thread): self.ls_condition_mgr = ls_condition_mgr self.market_guard = market_guard # MarketGuard (선택, None 이면 가드 없음) base_logger = get_logger(f"kis_trader.strategy.{self.strategy_id}") - self.logger = FeedPrefixLoggerAdapter(base_logger, {"db": self.db, "LIVE_TICK_PROVIDER": True}) + self.logger = FeedPrefixLoggerAdapter( + base_logger, + {"db": self.db, "ws": self.ws, "LIVE_TICK_PROVIDER": True}, + ) # MarketGuard PANIC 차단 로그 스팸 방지용 (분당 1회) self._panic_log_ts: float = 0.0 @@ -605,6 +632,21 @@ class BaseStrategy(ABC, threading.Thread): except Exception: return None + def _scan_log( + self, + level: str, + code: Optional[str], + msg: str, + *args: Any, + **kwargs: Any, + ) -> None: + """🔍 스캔/탈락 로그 — FeedPrefixLoggerAdapter 가 T/O/R 접두 부착.""" + extra = dict(kwargs.pop("extra", None) or {}) + c = str(code or "").strip() + if c: + extra["scan_code"] = c + getattr(self.logger, level)(msg, *args, extra=extra, **kwargs) + def _resolve_sell_price(self, code: str, *, is_eod: bool, buy_price: float) -> float: """실매 매도 현재가 — 마지막 WS를 TTL로 버리지 않음. EOD는 매수가 폴백.""" from kis_trader.engine.live_sell_price import resolve_live_sell_price @@ -1152,7 +1194,7 @@ class BaseStrategy(ABC, threading.Thread): t_pre0 = time.perf_counter() guard = self._live_portfolio_entry_guard(code, max_stocks) if guard: - self.logger.info("🔍 [%s] %s(%s)", guard, name, code) + self._scan_log("info", code, "🔍 [%s] %s(%s)", guard, name, code) if prof_scan: dt = (time.perf_counter() - t_pre0) * 1000.0 scan_pre_ms += dt diff --git a/kis_trader/strategies/breakout.py b/kis_trader/strategies/breakout.py index 6e5290f..f36f11f 100644 --- a/kis_trader/strategies/breakout.py +++ b/kis_trader/strategies/breakout.py @@ -1801,7 +1801,7 @@ class BreakoutStrategy(BaseStrategy): gap = 15.0 if get_env_bool("SCAN_REJECT_LOG_VERBOSE", False) else 60.0 if time.time() - last >= gap: self._no_price_log[code] = time.time() - self.logger.info("🔍 [탈락-가격없음] %s(%s) B안 WS시세 없음", name, code) + self._scan_log("info", code, "🔍 [탈락-가격없음] %s(%s) B안 WS시세 없음", name, code) return None forming = None @@ -1831,7 +1831,7 @@ class BreakoutStrategy(BaseStrategy): ) self._cb_prof_mark(_cb, "engine") if reason: - self.logger.info("🔍 [%s] %s(%s) %s", reason, name, code, msg or "") + self._scan_log("info", code, "🔍 [%s] %s(%s) %s", reason, name, code, msg or "") return None if not signal: return None @@ -1842,7 +1842,7 @@ class BreakoutStrategy(BaseStrategy): reason, msg, signal = check_buy_signal_breakout_live(confirmed, params) self._cb_prof_mark(_cb, "engine") if reason: - self.logger.info("🔍 [%s] %s(%s) %s", reason, name, code, msg or "") + self._scan_log("info", code, "🔍 [%s] %s(%s) %s", reason, name, code, msg or "") return None if not signal: return None @@ -1855,7 +1855,7 @@ class BreakoutStrategy(BaseStrategy): ) self._cb_prof_mark(_cb, "mid_enroll") if _defer: - self.logger.info("🔍 [%s] %s(%s)", _defer, name, code) + self._scan_log("info", code, "🔍 [%s] %s(%s)", _defer, name, code) return None if align_on and entry_open > 0: from kis_trader.engine.tail_tick_replay import live_align_entry_price @@ -1879,7 +1879,7 @@ class BreakoutStrategy(BaseStrategy): last = self._no_price_log.get(code, 0) if time.time() - last >= 60: self._no_price_log[code] = time.time() - self.logger.info("🔍 [가격없음] %s(%s)", name, code) + self._scan_log("info", code, "🔍 [가격없음] %s(%s)", name, code) else: self.logger.debug("🔍 [가격없음] %s(%s)", name, code) return None @@ -1981,10 +1981,16 @@ class BreakoutStrategy(BaseStrategy): if not hasattr(self, "_sell_no_price_log"): self._sell_no_price_log = {} self._sell_no_price_log[code] = time.time() - self.logger.warning( - "⚠️ [매도-가격없음] %s(%s) 마지막 WS·REST·폴백 없음 → 매도 판단 보류", - name, code, - ) + feed_lab = "" + try: + if hasattr(self.ws, "get_tick_feed_label"): + feed_lab = str(self.ws.get_tick_feed_label(code) or "").strip() + except Exception: + feed_lab = "" + self.logger.warning( + "⚠️ [매도-가격없음] %s(%s) WS·REST·폴백 없음 (R:%s) → 매도 판단 보류", + name, code, feed_lab or "?", + ) continue # 최고가·세션저점 갱신 (모멘텀·백테 intrabar 와 동일 — 폴링 사이 저점도 손절·트레일에 반영) diff --git a/kis_trader/strategies/momentum.py b/kis_trader/strategies/momentum.py index 0185ea3..a0cea90 100644 --- a/kis_trader/strategies/momentum.py +++ b/kis_trader/strategies/momentum.py @@ -151,7 +151,7 @@ class MomentumStrategy(BaseStrategy): return None if reject == "탈락-봉부족": return None - self.logger.info("🔍 [%s] %s %s: %s", reject, name, code, msg or "") + self._scan_log("info", code, "🔍 [%s] %s %s: %s", reject, name, code, msg or "") return None if not sig: return None @@ -164,7 +164,7 @@ class MomentumStrategy(BaseStrategy): ) self._cb_prof_mark(_cb, "mid_enroll") if _defer: - self.logger.info("🔍 [%s] %s(%s)", _defer, name, code) + self._scan_log("info", code, "🔍 [%s] %s(%s)", _defer, name, code) return None align_on = get_env_bool("MOMENTUM_LIVE_BACKTEST_ALIGN", True) @@ -206,7 +206,8 @@ class MomentumStrategy(BaseStrategy): ) self._cb_prof_mark(_cb, "qty") if rej: - self.logger.info( + self._scan_log( + "info", code, "🔍 [탈락-%s] %s(%s) price=%.0f", rej, name, code, curr_price, ) @@ -233,7 +234,7 @@ class MomentumStrategy(BaseStrategy): "entry_features": {"rsi": sig.get("rsi", 0)}, } except Exception as e: - self.logger.info("🔍 [탈락-예외] %s %s: %s", name, code, e) + self._scan_log("info", code, "🔍 [탈락-예외] %s %s: %s", name, code, e) return None finally: self._cb_prof_finish(_cb) diff --git a/kis_trader/strategies/scalping.py b/kis_trader/strategies/scalping.py index 9a6c35f..2c6a4aa 100644 --- a/kis_trader/strategies/scalping.py +++ b/kis_trader/strategies/scalping.py @@ -213,7 +213,7 @@ class ScalpingStrategy(BaseStrategy): reject, msg, sig = se.check_buy_signal_live(candles, params, state) self._cb_prof_mark(_cb, "engine") if reject: - self.logger.info("🔍 [%s] %s %s: %s", reject, name, code, msg or "") + self._scan_log("info", code, "🔍 [%s] %s %s: %s", reject, name, code, msg or "") return None if not sig: return None @@ -224,7 +224,7 @@ class ScalpingStrategy(BaseStrategy): ) self._cb_prof_mark(_cb, "mid_enroll") if _defer: - self.logger.info("🔍 [%s] %s(%s)", _defer, name, code) + self._scan_log("info", code, "🔍 [%s] %s(%s)", _defer, name, code) return None # 진입 계산가: align 시 T봉 첫 틱(RAM) → 없으면 시가. 폴백만 WS 현재가 @@ -262,7 +262,8 @@ class ScalpingStrategy(BaseStrategy): ) self._cb_prof_mark(_cb, "qty") if rej: - self.logger.info( + self._scan_log( + "info", code, "🔍 [탈락-%s] %s(%s) price=%.0f", rej, name, code, curr_price, ) @@ -290,7 +291,7 @@ class ScalpingStrategy(BaseStrategy): "entry_features": {"rsi": sig.get("rsi", 0)}, } except Exception as e: - self.logger.info("🔍 [탈락-예외] %s %s: %s", name, code, e) + self._scan_log("info", code, "🔍 [탈락-예외] %s %s: %s", name, code, e) return None finally: self._cb_prof_finish(_cb) diff --git a/kis_trader/strategies/tail_catch.py b/kis_trader/strategies/tail_catch.py index c7d08cb..3b6fe1a 100644 --- a/kis_trader/strategies/tail_catch.py +++ b/kis_trader/strategies/tail_catch.py @@ -192,7 +192,7 @@ class TailCatchStrategy(BaseStrategy): return None # 대형 주도주 등 하락매수 제외 종목 차단 (DIP_BUY_EXCLUDE_CODES 비면 무효) if self.is_dip_buy_excluded(code): - self.logger.info("🔍 [탈락-대형주제외] %s %s: DIP_BUY_EXCLUDE_CODES", name, code) + self._scan_log("info", code, "🔍 [탈락-대형주제외] %s %s: DIP_BUY_EXCLUDE_CODES", name, code) return None _cb = self._cb_prof_start(code) try: @@ -254,7 +254,7 @@ class TailCatchStrategy(BaseStrategy): reject, msg, sig = te.check_buy_signal_live(candles, params, state) self._cb_prof_mark(_cb, "engine") if reject: - self.logger.info("🔍 [%s] %s %s: %s", reject, name, code, msg or "") + self._scan_log("info", code, "🔍 [%s] %s %s: %s", reject, name, code, msg or "") return None if not sig: return None @@ -338,7 +338,7 @@ class TailCatchStrategy(BaseStrategy): ) self._cb_prof_mark(_cb, "mid_enroll") if _defer: - self.logger.info("🔍 [%s] %s(%s)", _defer, name, code) + self._scan_log("info", code, "🔍 [%s] %s(%s)", _defer, name, code) return None if align_on and entry_open > 0: from kis_trader.engine.tail_tick_replay import live_align_entry_price @@ -421,7 +421,7 @@ class TailCatchStrategy(BaseStrategy): }, } except Exception as e: - self.logger.info("🔍 [탈락-예외] %s %s: %s", name, code, e) + self._scan_log("info", code, "🔍 [탈락-예외] %s %s: %s", name, code, e) return None finally: self._cb_prof_finish(_cb) diff --git a/kis_trader/web/live_config_schema.py b/kis_trader/web/live_config_schema.py index 37732bc..6199363 100644 --- a/kis_trader/web/live_config_schema.py +++ b/kis_trader/web/live_config_schema.py @@ -188,17 +188,28 @@ def build_live_config_groups() -> List[GroupDef]: "갭보정은 키움 REST만. 영구구독 KR은 LS WS." ), "fields": [ - _f("LIVE_TICK_PROVIDER", "📊 실매매 틱(시세) 소스 (재시작 필요)", "text", default="kiwoom", + _f("LIVE_TICK_PROVIDER", "📊 실매매 틱(시세) 소스 (재시작 필요)", "select", default="kiwoom", + choices=[ + {"value": "kiwoom", "label": "키움 (kiwoom)"}, + {"value": "kis", "label": "한투 (kis)"}, + ], hint=( - "get_price 가 어느 RAM 을 먼저 볼지(1차). 폴백 나이 넘으면 실패로 보고 " - "2차(상대 체결소켓)→3차(LS)→매도는 4차 키움 REST(ka10007). " - "한투 60초 캐시는 안 씀. MINIMAL 이면 한투 RAM 은 보유만" + "1차 RAM. 키움=kiwoom / 한투=kis. LS는 여기 없음(3차 spill). " + "폴백 나이 넘으면 실패로 보고 2차(상대 체결소켓)→3차(LS)→매도는 4차 키움 REST(ka10007). " + "한투 60초 캐시는 안 씀. MINIMAL 이면 한투 RAM 은 보유만. " + "저장 후 kis_trader_main 재시작" )), - _f("LIVE_OB_PROVIDER", "📊 실매매 호가(OB) 소스 (재시작 필요)", "text", default="kiwoom", + _f("LIVE_OB_PROVIDER", "📊 실매매 호가(OB) 소스 (재시작 필요)", "select", default="kiwoom", + choices=[ + {"value": "kiwoom", "label": "키움 (kiwoom)"}, + {"value": "kis", "label": "한투 (kis)"}, + ], hint=( - "호가 1차. 폴백 나이 넘으면 그 벤더는 없는 것과 같고 2차→3차(LS UH1). " + "호가 1차. 키움=kiwoom / 한투=kis. LS는 여기 없음(3차 UH1). " + "폴백 나이 넘으면 그 벤더는 없는 것과 같고 2차→3차. " "만료로 None 만 만들어서 필터 ON 통과 구멍을 만들지 않음. " - "kis 선택 시 아래 'KIS 실시간 호가 수집' 도 ON" + "kis 선택 시 아래 'KIS 실시간 호가 수집' ON + 2번째 앱키(KIS_APP_KEY_OB_REAL). " + "저장 후 kis_trader_main 재시작" )), _f("LIVE_FEED_FALLBACK_MAX_AGE_SEC", "⏱ 시세·호가 읽기 폴백 나이(초)", "float", default=3.0, hint=( @@ -208,6 +219,18 @@ def build_live_config_groups() -> List[GroupDef]: "넘기면 그 벤더 실패. 적재(공책)와 분리. 기본 3. " "분봉 쓰레기 판정 나이도 이 값(봉 끝시각 대비)." )), + _f("TRIGGER_FEED_DETAIL_LOG", "📝 트리거 로그에 틱·호가·벤더 상세", "bool", default=True, + hint=( + "ON=매수체크(🔍) 로그 끝에 틱1차설정/실제(kis|kiwoom|ls)·현재가·틱타임·" + "호가 bid/ask/잔량비·스냅시각을 붙임. OFF=접두 T/O/R 만. 재시작 없이 반영(수 초)" + )), + _f("BT_FEED_DETAIL_LOG", "📝 옵투나/백테에도 틱·호가 벤더 추적", "bool", default=True, + hint=( + "ON=옵투나·백테 로그에 틱/호가 로드 벤더 비율 + TRIGGER 샘플 " + "(실매 TRIGGER_FEED_DETAIL_LOG 과 같은 축). OFF=샘플만 끔(비율 INFO는 유지)" + )), + _f("BT_FEED_DETAIL_LOG_MAX", "옵투나 TRIGGER 샘플 상한(건)", "int", default=40, + hint="프로세스당 매수 후보 상세 샘플 최대. 0=샘플 없음. 기본 40"), _f("CANDLE_GARBAGE_FALLBACK", "🕯 분봉 쓰레기→2차·3차 봉 통째", "bool", default=True, hint=( "ON(기본)=그 분 메인 틱이 0건이거나 전부 봉끝 대비 읽기나이 초과면 " @@ -403,6 +426,17 @@ def build_live_config_groups() -> List[GroupDef]: default=30.0, hint="죽은선 REST를 종목당 이 초마다 1회. 유량 보호. 코드 기본 30", ), + _f( + "SELL_WS_LAST_RAM_MAX_AGE_SEC", + "⏱ 매도 last-RAM 허용나이(초)", + "float", + default=30.0, + hint=( + "3초 체인 실패 시 마지막 WS RAM을 이 초까지 매도에 사용. " + "FID20 동결(패킷나이 초과)은 거부. 0=last-RAM 안 씀. " + "그 다음 직전가 캐시 → 키움 ka10007" + ), + ), _f( "WS_ORDERBOOK_FILTER_REJECT_IF_EMPTY", "⛔ 호가 사진 없으면 매수 안 함", diff --git a/kis_trader/ws/candle_series.py b/kis_trader/ws/candle_series.py index 818c2d7..f0765ea 100644 --- a/kis_trader/ws/candle_series.py +++ b/kis_trader/ws/candle_series.py @@ -91,6 +91,55 @@ def live_read_label() -> str: return f"LIVE({main})+{aux}+ls+kiwoom_rest" +def _index_ticks_by_minute(ticks: Sequence[Dict[str, Any]]) -> Dict[Tuple[str, str], List[Dict[str, Any]]]: + """(source, YYYYMMDDHHMM) → 틱. source 빈 틱은 ('*', 분) — 모든 증권사 쓰레기검사에 포함.""" + idx: Dict[Tuple[str, str], List[Dict[str, Any]]] = {} + for t in ticks or []: + if not isinstance(t, dict): + continue + raw = str(t.get("tick_time_raw") or t.get("tick_time") or "").strip() + digits = raw.replace(":", "").replace("-", "").replace(" ", "") + tmin = digits[:12] if len(digits) >= 12 else "" + if len(tmin) < 12 or not tmin.isdigit(): + continue + tsrc = str(t.get("source") or "").strip().lower() + key = (tsrc if tsrc else "*", tmin) + idx.setdefault(key, []).append(t) + return idx + + +def _ticks_in_bar_from_index( + idx: Optional[Dict[Tuple[str, str], List[Dict[str, Any]]]], + source: str, + candle_time: str, + tf_min: int, +) -> List[Dict[str, Any]]: + """tick_in_bar_bucket 과 같은 분 구간만 — bar_is_garbage 전체 스캔 방지.""" + from kis_trader.engine.candle_rollup import add_candle_minutes + + if not idx: + return [] + ct = str(candle_time or "").strip()[:12] + if len(ct) < 12: + return [] + end = add_candle_minutes(ct, max(1, int(tf_min or 1))) + if not end: + return [] + end12 = end[:12] + src = str(source or "").strip().lower() + out: List[Dict[str, Any]] = [] + cur = ct + while cur and cur < end12: + if src: + out.extend(idx.get((src, cur), ())) + out.extend(idx.get(("*", cur), ())) + nxt = add_candle_minutes(cur, 1) + if not nxt or nxt[:12] <= cur: + break + cur = nxt[:12] + return out + + def dedupe_by_read_pairs( rows: Sequence[Dict[str, Any]], pairs: Optional[Sequence[ReadPair]] = None, @@ -99,11 +148,13 @@ def dedupe_by_read_pairs( tf_min: int = 1, missing_policy: str = "hole", live_cover: bool = False, + stats: Optional[Dict[str, Any]] = None, ) -> List[Dict[str, Any]]: """candle_time 1행 — pairs 앞쪽이 이김. source/channel 은 결과에 남기지 않음. ticks 가 있으면 WS 봉만 쓰레기(0건·봉끝 나이초과)일 때 다음 쌍. 한 봉 혼합 없음. live_cover=True 이면 링이 그 분을 커버 못할 때 그 WS 봉을 유지. + stats 가 있으면 pick/garbage/hole 누적 (옵투나 로그용). """ from kis_trader.engine.feed_fallback import bar_is_garbage, live_bar_is_garbage @@ -123,30 +174,59 @@ def dedupe_by_read_pairs( by_ct[ct][pair] = dict(row) tf = max(1, int(tf_min or 1)) + tick_idx = None + if ticks is not None: + tick_idx = _index_ticks_by_minute(ticks) + if stats is not None: + stats.setdefault("slots", 0) + stats.setdefault("picked", 0) + stats.setdefault("hole", 0) + stats.setdefault("garbage_skip", 0) + stats.setdefault("pick_by", {}) + stats.setdefault("garbage_by", {}) + stats.setdefault("raw_by", {}) + for ct_map in by_ct.values(): + for pair in ct_map: + key = "%s/%s" % pair + stats["raw_by"][key] = int(stats["raw_by"].get(key) or 0) + 1 out: List[Dict[str, Any]] = [] for ct in sorted(by_ct.keys()): + if stats is not None: + stats["slots"] = int(stats.get("slots") or 0) + 1 picked: Optional[Dict[str, Any]] = None + pick_key = "" for pair in order: bar = by_ct[ct].get(pair) if bar is None: continue src, ch = pair if ticks is not None and ch == CHANNEL_WS: + scoped = _ticks_in_bar_from_index(tick_idx, src, ct, tf) if live_cover: bad = live_bar_is_garbage( ticks, candle_time=ct, tf_min=tf, source=src, ) else: bad = bar_is_garbage( - ticks, candle_time=ct, tf_min=tf, source=src, + scoped, candle_time=ct, tf_min=tf, source=src, missing_policy=missing_policy, ) if bad: + if stats is not None: + stats["garbage_skip"] = int(stats.get("garbage_skip") or 0) + 1 + gk = "%s/%s" % pair + stats["garbage_by"][gk] = int(stats["garbage_by"].get(gk) or 0) + 1 continue picked = dict(bar) + pick_key = "%s/%s" % pair break if picked is None: + if stats is not None: + stats["hole"] = int(stats.get("hole") or 0) + 1 continue + if stats is not None: + stats["picked"] = int(stats.get("picked") or 0) + 1 + stats["pick_by"][pick_key] = int(stats["pick_by"].get(pick_key) or 0) + 1 picked.pop("source", None) picked.pop("channel", None) out.append(picked) diff --git a/kis_trader/ws/kis_ws.py b/kis_trader/ws/kis_ws.py index 02fba9c..4777799 100644 --- a/kis_trader/ws/kis_ws.py +++ b/kis_trader/ws/kis_ws.py @@ -909,6 +909,8 @@ class KISWebSocketPriceCache: if tick_time_raw: data_compat["kis_cntg_hour_raw"] = cntg_hour_raw data_compat["kis_bsop_date_raw"] = bsop_date_raw + data_compat["tick_time"] = str(tick_time_raw) + data_compat["chetime"] = str(cntg_hour_raw or "") # 읽기 RAM: 체결시각 vs 지금(2초). 적재(TickRecorder)는 버리지 않음. skip_ram = False diff --git a/kis_trader/ws/kiwoom_ws.py b/kis_trader/ws/kiwoom_ws.py index c065cc4..3c7889d 100644 --- a/kis_trader/ws/kiwoom_ws.py +++ b/kis_trader/ws/kiwoom_ws.py @@ -808,6 +808,8 @@ class KiwoomWebSocketPriceCache: "kiwoom_fid228": str(exec_raw), "kiwoom_fid567": upper_limit_raw, "kiwoom_fid568": lower_limit_raw, + "chetime": "", + "kiwoom_fid20": "", } # tick_time / tick_vol — CandleAggregator·TickRecorder 공용 (보유-only도 recorder 수집) @@ -823,6 +825,11 @@ class KiwoomWebSocketPriceCache: tick_time_pkt = tt_raw[-6:] else: tick_time_pkt = _now_dt.strftime("%H%M%S") + data_compat["chetime"] = tick_time_pkt + data_compat["kiwoom_fid20"] = tick_time_pkt + data_compat["tick_time"] = ( + tt_raw[:14] if len(tt_raw) >= 14 else (_now_dt.strftime("%Y%m%d") + tick_time_pkt) + ) _pkt_dt = None try: if len(tt_raw) >= 14: diff --git a/kis_trader/ws/tick_recorder.py b/kis_trader/ws/tick_recorder.py index 4d2a9b5..0f030f7 100644 --- a/kis_trader/ws/tick_recorder.py +++ b/kis_trader/ws/tick_recorder.py @@ -131,6 +131,8 @@ class TickRecorder: "session": (session or "")[:8] or None, "currency": (currency or self._default_currency).upper()[:8], "source": (source or "kis")[:16], + # 봉과 동일: 경로. 체결 틱은 소켓만 적재 (ka10007 REST 현재가 INSERT 금지) + "channel": "ws", "recv_ts": recv_ts, } if cntr_str is not None: diff --git a/logs/kis_api_rejects.jsonl b/logs/kis_api_rejects.jsonl index 0cd1610..f88003b 100644 --- a/logs/kis_api_rejects.jsonl +++ b/logs/kis_api_rejects.jsonl @@ -5076,3 +5076,7 @@ {"ts": "2026-08-18T15:30:05+09:00", "kind": "domestic_order_reject", "strategy_id": "", "side": "SELL", "code": "009520", "msg_cd": "40580000", "msg1": "모의투자 장종료 입니다.", "rt_cd": "1", "http": 200, "path": "/uapi/domestic-stock/v1/trading/order-cash", "fingerprint": "f34b1e0fbbfb6b4e", "extra": {"mock": true}} {"ts": "2026-08-18T15:30:20+09:00", "kind": "domestic_order_reject", "strategy_id": "", "side": "SELL", "code": "033160", "msg_cd": "40580000", "msg1": "모의투자 장종료 입니다.", "rt_cd": "1", "http": 200, "path": "/uapi/domestic-stock/v1/trading/order-cash", "fingerprint": "9a68dfa02029dda4", "extra": {"mock": true}} {"ts": "2026-08-19T12:43:01+09:00", "kind": "domestic_order_reject", "strategy_id": "", "side": "SELL", "code": "038680", "msg_cd": "40240000", "msg1": "모의투자 잔고내역이 없습니다.", "rt_cd": "1", "http": 200, "path": "/uapi/domestic-stock/v1/trading/order-cash", "fingerprint": "d64a4218f5f98f5e", "extra": {"mock": true}} +{"ts": "2026-08-21T15:31:43+09:00", "kind": "domestic_order_reject", "strategy_id": "", "side": "SELL", "code": "490470", "msg_cd": "40580000", "msg1": "모의투자 장종료 입니다.", "rt_cd": "1", "http": 200, "path": "/uapi/domestic-stock/v1/trading/order-cash", "fingerprint": "eee3d14e7e1a9629", "extra": {"mock": true}} +{"ts": "2026-08-21T15:40:02+09:00", "kind": "domestic_order_reject", "strategy_id": "", "side": "SELL", "code": "490470", "msg_cd": "40580000", "msg1": "모의투자 장종료 입니다.", "rt_cd": "1", "http": 200, "path": "/uapi/domestic-stock/v1/trading/order-cash", "fingerprint": "eee3d14e7e1a9629", "extra": {"mock": true}} +{"ts": "2026-08-21T15:47:18+09:00", "kind": "domestic_order_reject", "strategy_id": "", "side": "SELL", "code": "490470", "msg_cd": "40580000", "msg1": "모의투자 장종료 입니다.", "rt_cd": "1", "http": 200, "path": "/uapi/domestic-stock/v1/trading/order-cash", "fingerprint": "eee3d14e7e1a9629", "extra": {"mock": true}} +{"ts": "2026-08-21T15:55:43+09:00", "kind": "domestic_order_reject", "strategy_id": "", "side": "SELL", "code": "490470", "msg_cd": "40580000", "msg1": "모의투자 장종료 입니다.", "rt_cd": "1", "http": 200, "path": "/uapi/domestic-stock/v1/trading/order-cash", "fingerprint": "eee3d14e7e1a9629", "extra": {"mock": true}} diff --git a/logs/opt_20260821_102454_seq_seq_active.txt b/logs/opt_20260821_102454_seq_seq_active.txt new file mode 100644 index 0000000..6aef692 --- /dev/null +++ b/logs/opt_20260821_102454_seq_seq_active.txt @@ -0,0 +1,5 @@ +strategy=scalp +study=scalp_tpe_20260818_20260820_20260821_120156 +entry_mode= +sl_mode= +extra= diff --git a/logs/optuna_4strat_tpe_latest_master.logpath b/logs/optuna_4strat_tpe_latest_master.logpath index fd2742f..25bf545 100644 --- a/logs/optuna_4strat_tpe_latest_master.logpath +++ b/logs/optuna_4strat_tpe_latest_master.logpath @@ -1 +1 @@ -logs/optuna_4strat_tpe_2026-08-17_2026-08-19_20260819_092648_master.log +logs/optuna_4strat_tpe_2026-08-18_2026-08-20_20260821_102454_master.log diff --git a/logs/optuna_breakout_latest.jsonpath b/logs/optuna_breakout_latest.jsonpath index 38764ac..ef1c31d 100644 --- a/logs/optuna_breakout_latest.jsonpath +++ b/logs/optuna_breakout_latest.jsonpath @@ -1 +1 @@ -/home/hoon/kis_bot/kis_trader/backtest/results/optuna_breakout_tpe_20260819_092934.json +/home/hoon/kis_bot/kis_trader/backtest/results/optuna_breakout_tpe_20260821_120015.json diff --git a/logs/optuna_breakout_tpe_latest.jsonpath b/logs/optuna_breakout_tpe_latest.jsonpath index 38764ac..ef1c31d 100644 --- a/logs/optuna_breakout_tpe_latest.jsonpath +++ b/logs/optuna_breakout_tpe_latest.jsonpath @@ -1 +1 @@ -/home/hoon/kis_bot/kis_trader/backtest/results/optuna_breakout_tpe_20260819_092934.json +/home/hoon/kis_bot/kis_trader/backtest/results/optuna_breakout_tpe_20260821_120015.json diff --git a/logs/optuna_breakout_tpe_latest.logpath b/logs/optuna_breakout_tpe_latest.logpath index d7fccfa..0b2240c 100644 --- a/logs/optuna_breakout_tpe_latest.logpath +++ b/logs/optuna_breakout_tpe_latest.logpath @@ -1 +1 @@ -logs/optuna_breakout_atr_tpe_20260819_092820.log +logs/optuna_breakout_atr_tpe_20260821_114820.log diff --git a/logs/optuna_breakout_tpe_latest.study b/logs/optuna_breakout_tpe_latest.study index 8c264bd..26aef1f 100644 --- a/logs/optuna_breakout_tpe_latest.study +++ b/logs/optuna_breakout_tpe_latest.study @@ -1 +1 @@ -breakout_atr_tpe_20260817_20260819_20260819_092820 +breakout_atr_tpe_20260818_20260820_20260821_114820 diff --git a/logs/optuna_latest.jsonpath b/logs/optuna_latest.jsonpath index 38764ac..f6a86f5 100644 --- a/logs/optuna_latest.jsonpath +++ b/logs/optuna_latest.jsonpath @@ -1 +1 @@ -/home/hoon/kis_bot/kis_trader/backtest/results/optuna_breakout_tpe_20260819_092934.json +/home/hoon/kis_bot/kis_trader/backtest/results/optuna_momentum_tpe_20260821_151059.json diff --git a/logs/optuna_momentum_latest.jsonpath b/logs/optuna_momentum_latest.jsonpath index a817464..f6a86f5 100644 --- a/logs/optuna_momentum_latest.jsonpath +++ b/logs/optuna_momentum_latest.jsonpath @@ -1 +1 @@ -/home/hoon/kis_bot/kis_trader/backtest/results/optuna_momentum_tpe_20260816_031742.json +/home/hoon/kis_bot/kis_trader/backtest/results/optuna_momentum_tpe_20260821_151059.json diff --git a/logs/optuna_momentum_tpe_latest.jsonpath b/logs/optuna_momentum_tpe_latest.jsonpath index a817464..f6a86f5 100644 --- a/logs/optuna_momentum_tpe_latest.jsonpath +++ b/logs/optuna_momentum_tpe_latest.jsonpath @@ -1 +1 @@ -/home/hoon/kis_bot/kis_trader/backtest/results/optuna_momentum_tpe_20260816_031742.json +/home/hoon/kis_bot/kis_trader/backtest/results/optuna_momentum_tpe_20260821_151059.json diff --git a/logs/optuna_momentum_tpe_latest.logpath b/logs/optuna_momentum_tpe_latest.logpath index 3d01f29..cd11edd 100644 --- a/logs/optuna_momentum_tpe_latest.logpath +++ b/logs/optuna_momentum_tpe_latest.logpath @@ -1 +1 @@ -logs/optuna_momentum_tpe_20260816_025631.log +logs/optuna_momentum_tpe_20260821_102454.log diff --git a/logs/optuna_momentum_tpe_latest.study b/logs/optuna_momentum_tpe_latest.study index 2893bd8..ec51909 100644 --- a/logs/optuna_momentum_tpe_latest.study +++ b/logs/optuna_momentum_tpe_latest.study @@ -1 +1 @@ -momentum_tpe_20260812_20260814_20260816_025631 +momentum_tpe_20260818_20260820_20260821_102454 diff --git a/logs/optuna_scalp_latest.jsonpath b/logs/optuna_scalp_latest.jsonpath index 3bfc242..a5e35e1 100644 --- a/logs/optuna_scalp_latest.jsonpath +++ b/logs/optuna_scalp_latest.jsonpath @@ -1 +1 @@ -/home/hoon/kis_bot/kis_trader/backtest/results/optuna_scalp_tpe_20260819_025229.json +/home/hoon/kis_bot/kis_trader/backtest/results/optuna_scalp_tpe_20260821_122053.json diff --git a/logs/optuna_scalp_tpe_latest.jsonpath b/logs/optuna_scalp_tpe_latest.jsonpath index 3bfc242..a5e35e1 100644 --- a/logs/optuna_scalp_tpe_latest.jsonpath +++ b/logs/optuna_scalp_tpe_latest.jsonpath @@ -1 +1 @@ -/home/hoon/kis_bot/kis_trader/backtest/results/optuna_scalp_tpe_20260819_025229.json +/home/hoon/kis_bot/kis_trader/backtest/results/optuna_scalp_tpe_20260821_122053.json diff --git a/logs/optuna_scalp_tpe_latest.logpath b/logs/optuna_scalp_tpe_latest.logpath index 12f46c6..d319729 100644 --- a/logs/optuna_scalp_tpe_latest.logpath +++ b/logs/optuna_scalp_tpe_latest.logpath @@ -1 +1 @@ -logs/optuna_scalp_tpe_20260816_043425.log +logs/optuna_scalp_tpe_20260821_120156.log diff --git a/logs/optuna_scalp_tpe_latest.study b/logs/optuna_scalp_tpe_latest.study index 717491b..573f227 100644 --- a/logs/optuna_scalp_tpe_latest.study +++ b/logs/optuna_scalp_tpe_latest.study @@ -1 +1 @@ -scalp_tpe_20260812_20260814_20260816_043425 +scalp_tpe_20260818_20260820_20260821_120156 diff --git a/logs/optuna_tail_latest.jsonpath b/logs/optuna_tail_latest.jsonpath index 96e4669..89f6547 100644 --- a/logs/optuna_tail_latest.jsonpath +++ b/logs/optuna_tail_latest.jsonpath @@ -1 +1 @@ -/home/hoon/kis_bot/kis_trader/backtest/results/optuna_tail_tpe_20260816_040252.json +/home/hoon/kis_bot/kis_trader/backtest/results/optuna_tail_tpe_20260821_113102.json diff --git a/logs/optuna_tail_tpe_latest.jsonpath b/logs/optuna_tail_tpe_latest.jsonpath index 96e4669..89f6547 100644 --- a/logs/optuna_tail_tpe_latest.jsonpath +++ b/logs/optuna_tail_tpe_latest.jsonpath @@ -1 +1 @@ -/home/hoon/kis_bot/kis_trader/backtest/results/optuna_tail_tpe_20260816_040252.json +/home/hoon/kis_bot/kis_trader/backtest/results/optuna_tail_tpe_20260821_113102.json diff --git a/logs/optuna_tail_tpe_latest.logpath b/logs/optuna_tail_tpe_latest.logpath index 3bd7aec..35a7050 100644 --- a/logs/optuna_tail_tpe_latest.logpath +++ b/logs/optuna_tail_tpe_latest.logpath @@ -1 +1 @@ -logs/optuna_tail_limit_atr_tpe_20260816_035149.log +logs/optuna_tail_limit_atr_tpe_20260821_111542.log diff --git a/logs/optuna_tail_tpe_latest.study b/logs/optuna_tail_tpe_latest.study index 6c62f08..d03b34f 100644 --- a/logs/optuna_tail_tpe_latest.study +++ b/logs/optuna_tail_tpe_latest.study @@ -1 +1 @@ -tail_limit_atr_tpe_20260812_20260814_20260816_035149 +tail_limit_atr_tpe_20260818_20260820_20260821_111542 diff --git a/logs/optuna_web_latest_job.txt b/logs/optuna_web_latest_job.txt index c8886fa..b9e94d2 100644 --- a/logs/optuna_web_latest_job.txt +++ b/logs/optuna_web_latest_job.txt @@ -1 +1 @@ -opt_20260819_092648_seq +opt_20260821_145331_mome diff --git a/scripts/run_optuna_4strat_tpe_seq.sh b/scripts/run_optuna_4strat_tpe_seq.sh index 4e0e379..5ea2bb9 100755 --- a/scripts/run_optuna_4strat_tpe_seq.sh +++ b/scripts/run_optuna_4strat_tpe_seq.sh @@ -18,6 +18,7 @@ START="${START:-2026-07-20}" END="${END:-2026-07-21}" MODE="${MODE:-tpe}" TRIALS="${TRIALS:-200}" +STUDY_TRIALS="${STUDY_TRIALS:-}" MIN_TRADES="${MIN_TRADES:-1}" # 탐색 게이트 OFF — TPE 학습. 사후 후보는 JSON results_gated (REPORT_MIN_*) MIN_WIN_RATE="${MIN_WIN_RATE:-0}" @@ -40,7 +41,7 @@ MASTER="logs/optuna_4strat_tpe_${START}_${END}_${TS0}_master.log" { echo "======== Optuna 4전략 TPE 순차 시작 $(date -Is) ========" - echo "START=$START END=$END MODE=$MODE TRIALS=$TRIALS" + echo "START=$START END=$END MODE=$MODE TRIALS=$TRIALS STUDY_TRIALS=${STUDY_TRIALS:-}" echo "STRATEGIES=$STRATEGIES" echo "TAIL_OPTUNA_ENTRY_MODES=$TAIL_OPTUNA_ENTRY_MODES" echo "BREAKOUT_OPTUNA_SL_MODES=$BREAKOUT_OPTUNA_SL_MODES" @@ -80,6 +81,17 @@ run_one() { } | 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 + extra="${entry_mode:-${sl_mode:-}}" + { + echo "strategy=$strat" + echo "study=$study" + echo "entry_mode=${entry_mode:-}" + echo "sl_mode=${sl_mode:-}" + echo "extra=${extra}" + } > "$OPTUNA_SEQ_ACTIVE_FILE" + fi set +e set +e @@ -98,6 +110,9 @@ run_one() { --sort-by "$sort_by" --universe-history-source "$UNIVERSE_HISTORY_SOURCE" ) + if [[ -n "${STUDY_TRIALS:-}" && "${STUDY_TRIALS}" != "0" ]]; then + cmd_args+=(--study-trials "$STUDY_TRIALS") + fi if [[ "$strat" == "tail" && -n "$entry_mode" ]]; then cmd_args+=(--entry-mode "$entry_mode") fi diff --git a/static/css/backtest.css b/static/css/backtest.css index 6a95222..133733d 100644 --- a/static/css/backtest.css +++ b/static/css/backtest.css @@ -281,6 +281,40 @@ .optuna-prog-fill.is-error { background: var(--red); } .optuna-prog-fill.is-post { background: #d29922; } + .opt-join-overlay { + display: none; + position: fixed; + inset: 0; + z-index: 2200; + background: rgba(0, 0, 0, 0.62); + align-items: center; + justify-content: center; + padding: 16px; + } + .opt-join-overlay.is-open { display: flex; } + .opt-join-panel { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + max-width: 860px; + width: 100%; + max-height: 90vh; + overflow: auto; + padding: 16px; + } + .opt-join-pre { + font-size: 11px; + white-space: pre-wrap; + word-break: break-all; + background: #0d1117; + border: 1px solid var(--border); + border-radius: 6px; + padding: 10px; + color: var(--text); + max-height: 220px; + overflow: auto; + } + /* 오늘 운영 — 조건검색 이력(키움/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 c000686..0bda173 100644 --- a/static/js/backtest.js +++ b/static/js/backtest.js @@ -715,58 +715,94 @@ function doughnutChart(id, labels, data) { } // ──────────────────────────────────────────── -// 탭 전환 +// 탭 전환 (새로고침해도 마지막 탭 유지) // ──────────────────────────────────────────── +const BT_TAB_KEY = 'kis_bt_active_tab'; +const BT_TAB_NAMES = [ + 'actual', 'dashboard', 'portfolio', 'backtest', 'tail', 'dbband', 'breakout', + 'range_break', 'momentum', 'us_momentum', 'optuna', 'holding', 'updownbox', + 'updow', 'dart', 'liveconfig', +]; + +function btShowTab(tab, opts) { + opts = opts || {}; + if (!BT_TAB_NAMES.includes(tab)) tab = 'actual'; + const el = document.querySelector('[data-tab="' + tab + '"]'); + if (!el) return; + document.querySelectorAll('[data-tab]').forEach(x => x.classList.remove('active')); + el.classList.add('active'); + $('tab-actual').style.display = tab === 'actual' ? '' : 'none'; + $('tab-dashboard').style.display = tab === 'dashboard'? '' : 'none'; + $('tab-portfolio').style.display = tab === 'portfolio'? '' : 'none'; + $('tab-backtest').style.display = tab === 'backtest' ? '' : 'none'; + $('tab-tail').style.display = tab === 'tail' ? '' : 'none'; + $('tab-dbband').style.display = tab === 'dbband' ? '' : 'none'; + $('tab-breakout').style.display = tab === 'breakout' ? '' : 'none'; + $('tab-range_break').style.display = tab === 'range_break' ? '' : 'none'; + $('tab-momentum').style.display = tab === 'momentum' ? '' : 'none'; + $('tab-us_momentum').style.display = tab === 'us_momentum' ? '' : 'none'; + $('tab-optuna').style.display = tab === 'optuna' ? '' : 'none'; + $('tab-holding').style.display = tab === 'holding' ? '' : 'none'; + $('tab-updownbox').style.display = tab === 'updownbox'? '' : 'none'; + $('tab-updow').style.display = tab === 'updow' ? '' : 'none'; + $('tab-dart').style.display = tab === 'dart' ? '' : 'none'; + $('tab-liveconfig').style.display = tab === 'liveconfig' ? '' : 'none'; + try { localStorage.setItem(BT_TAB_KEY, tab); } catch (e) { /* ignore */ } + if (!opts.fromHash) { + try { + if (location.hash !== '#' + tab) history.replaceState(null, '', '#' + tab); + } catch (e) { /* ignore */ } + } + if (tab === 'dashboard') loadDashboard(); + if (tab === 'liveconfig') lcOnTabShow(); + if (tab === 'portfolio') pfLoad(true); + if (tab === 'holding') hdLoadStocks(); + if (tab === 'updow') permLoad(); + if (tab === 'us_momentum') { + updateUsMomFxHint(); + usmomLoadStocks(false); + usmomOptSyncDatesFromMain(); + if (typeof _usmomOptJobId !== 'undefined' && _usmomOptJobId) usmomStockOptunaStartPolling(); + } + if (tab === 'dart') dartOnTabShow(); + if (tab === 'updownbox') ubxOnTabShow(); + if (tab === 'dbband') dbLoadStocks(); + if (tab === 'breakout' && typeof boSyncSlModeColors === 'function') boSyncSlModeColors(); + if (tab === 'optuna' && typeof optunaOnTabShow === 'function') optunaOnTabShow(); + // 보유 라이브 시세: 보유·실거래 탭에서만 SSE/폴링 (다른 탭 이탈 시 중지) + if (tab === 'portfolio') { + /* pfRenderTable 에서 start */ + } else if (tab === 'actual') { + const at = $('act_tbody'); + if (at && at.querySelector('[data-live-code]') && typeof liveQuoteStartFromDom === 'function') { + liveQuoteStartFromDom(at); + } else if (typeof liveQuoteStop === 'function') { + liveQuoteStop(); + } + } else if (typeof liveQuoteStop === 'function') { + liveQuoteStop(); + } +} + +function btRestoreTab() { + let tab = (location.hash || '').replace(/^#/, ''); + if (!BT_TAB_NAMES.includes(tab)) { + try { tab = localStorage.getItem(BT_TAB_KEY) || ''; } catch (e) { tab = ''; } + } + if (!BT_TAB_NAMES.includes(tab)) tab = 'actual'; + btShowTab(tab); +} + document.querySelectorAll('[data-tab]').forEach(el => { el.addEventListener('click', e => { e.preventDefault(); - document.querySelectorAll('[data-tab]').forEach(x => x.classList.remove('active')); - el.classList.add('active'); - const tab = el.dataset.tab; - $('tab-actual').style.display = tab === 'actual' ? '' : 'none'; - $('tab-dashboard').style.display = tab === 'dashboard'? '' : 'none'; - $('tab-portfolio').style.display = tab === 'portfolio'? '' : 'none'; - $('tab-backtest').style.display = tab === 'backtest' ? '' : 'none'; - $('tab-tail').style.display = tab === 'tail' ? '' : 'none'; - $('tab-dbband').style.display = tab === 'dbband' ? '' : 'none'; - $('tab-breakout').style.display = tab === 'breakout' ? '' : 'none'; - $('tab-range_break').style.display = tab === 'range_break' ? '' : 'none'; - $('tab-momentum').style.display = tab === 'momentum' ? '' : 'none'; - $('tab-us_momentum').style.display = tab === 'us_momentum' ? '' : 'none'; - $('tab-optuna').style.display = tab === 'optuna' ? '' : 'none'; - $('tab-holding').style.display = tab === 'holding' ? '' : 'none'; - $('tab-updownbox').style.display = tab === 'updownbox'? '' : 'none'; - $('tab-updow').style.display = tab === 'updow' ? '' : 'none'; - $('tab-dart').style.display = tab === 'dart' ? '' : 'none'; - $('tab-liveconfig').style.display = tab === 'liveconfig' ? '' : 'none'; - if (tab === 'dashboard') loadDashboard(); - if (tab === 'liveconfig') lcOnTabShow(); - if (tab === 'portfolio') pfLoad(true); - if (tab === 'holding') hdLoadStocks(); - if (tab === 'updow') permLoad(); - if (tab === 'us_momentum') { - updateUsMomFxHint(); - usmomLoadStocks(false); - usmomOptSyncDatesFromMain(); - if (_usmomOptJobId) usmomStockOptunaStartPolling(); - } - if (tab === 'dart') dartOnTabShow(); - if (tab === 'updownbox') ubxOnTabShow(); - if (tab === 'dbband') dbLoadStocks(); - if (tab === 'breakout' && typeof boSyncSlModeColors === 'function') boSyncSlModeColors(); - if (tab === 'optuna') optunaOnTabShow(); - // 보유 라이브 시세: 보유·실거래 탭에서만 SSE/폴링 (다른 탭 이탈 시 중지) - if (tab === 'portfolio') { - /* pfRenderTable 에서 start */ - } else if (tab === 'actual') { - const at = $('act_tbody'); - if (at && at.querySelector('[data-live-code]')) liveQuoteStartFromDom(at); - else liveQuoteStop(); - } else { - liveQuoteStop(); - } + btShowTab(el.dataset.tab); }); }); +window.addEventListener('hashchange', () => { + const tab = (location.hash || '').replace(/^#/, ''); + if (BT_TAB_NAMES.includes(tab)) btShowTab(tab, { fromHash: true }); +}); // ════════════════════════════════════════════════════════════════ // DART 수주 공시 @@ -1247,20 +1283,7 @@ function dashDrill(strategyId) { $('act_start').value = day; $('act_end').value = day; } - document.querySelectorAll('[data-tab]').forEach(x => x.classList.remove('active')); - const actTab = document.querySelector('[data-tab="actual"]'); - if (actTab) actTab.classList.add('active'); - $('tab-dashboard').style.display = 'none'; - $('tab-actual').style.display = ''; - $('tab-portfolio').style.display = 'none'; - $('tab-backtest').style.display = 'none'; - $('tab-tail').style.display = 'none'; - $('tab-breakout').style.display = 'none'; - $('tab-range_break').style.display = 'none'; - $('tab-momentum').style.display = 'none'; - $('tab-us_momentum').style.display = 'none'; - $('tab-holding').style.display = 'none'; - $('tab-updow').style.display = 'none'; + btShowTab('actual'); loadActual(); } function dashGoActual() { @@ -2992,8 +3015,7 @@ function usmomGoOptuna() { cb.checked = (cb.value === 'us_momentum'); }); document.querySelectorAll('.opt-tail-em').forEach(cb => { cb.checked = false; }); - const el = document.querySelector('[data-tab="optuna"]'); - if (el) el.click(); + btShowTab('optuna'); } /** 해외 모멘텀 → 실거래 분석 탭 (국내 전략 탭과 동일: 전략탭에 실거래 UI 없음) */ @@ -3003,8 +3025,7 @@ function usmomActGoActualTab() { const day = ($('act_start')?.value || '').trim() || kstTradingDayIso(_krHolidays); setDateVal('act_start', day); setDateVal('act_end', day); - const el = document.querySelector('[data-tab="actual"]'); - if (el) el.click(); + btShowTab('actual'); loadActual(); } @@ -3422,12 +3443,16 @@ async function usmomStockOptunaStart() { usmomOptSyncDatesToMain(); if ($('usmom_bt_codes')) $('usmom_bt_codes').value = code; const trials = parseInt($('usmom_opt_trials')?.value || '100', 10) || 100; + let studyTrials = parseInt($('usmom_opt_study_trials')?.value, 10); + if (!Number.isFinite(studyTrials) || studyTrials <= 0) studyTrials = null; const mode = ($('usmom_opt_mode')?.value || 'tpe').trim() || 'tpe'; const candleSrc = $('opt_candle_source')?.value || ''; const tickSrc = $('opt_tick_source')?.value || ''; const obSrc = $('opt_ob_source')?.value || ''; if (!confirm( - `종목 Optuna 시작?\n${code} (1종목 유니버스)\n${start}~${end} trials=${trials}\n` + + `종목 Optuna 시작?\n${code} (1종목 유니버스)\n${start}~${end} trials=${trials}` + + (studyTrials ? `\n스터디 총 횟수=${studyTrials}` : '') + + `\n` + `완료 후 「폼에 넣기」→백테 검증 · 「종목행에 적용」→ stock_config\n(전역 US_MOMENTUM_* 미적용)` )) return; try { @@ -3438,6 +3463,7 @@ async function usmomStockOptunaStart() { strategy: 'us_momentum', symbol: code, start, end, trials, mode, + study_trials: studyTrials || undefined, candle_source: candleSrc, tick_source: tickSrc, ob_source: obSrc, @@ -5597,8 +5623,7 @@ const BTJOB_CFG = { function btJobGoTab(strat) { const s = strat || _btJobStrategy || 'tail'; const cfg = BTJOB_CFG[s] || BTJOB_CFG.tail; - const el = document.querySelector('[data-tab="' + cfg.tab + '"]'); - if (el) el.click(); + btShowTab(cfg.tab); } function btJobClearUi(strat, msg) { @@ -6848,15 +6873,21 @@ function lcFieldInput(f) { } if (f.type === 'select' && Array.isArray(f.choices)) { const val = f.value != null ? String(f.value) : ''; + const valNorm = val.toLowerCase(); + let matched = false; const opts = f.choices.map(c => { const cv = (typeof c === 'object') ? String(c.value) : String(c); const cl = (typeof c === 'object') ? String(c.label != null ? c.label : c.value) : String(c); - const sel = (cv === val) ? ' selected' : ''; + const sel = (cv === val || cv.toLowerCase() === valNorm) ? ' selected' : ''; + if (sel) matched = true; return ``; }).join(''); + const extra = (!matched && val) + ? `` + : ''; return `
${tbl} - + ${hint}
`; } @@ -7570,10 +7601,26 @@ function showSpinner(v) { const OPTUNA_LS_KEY = 'kis_optuna_job_id'; let _optunaPollTimer = null; let _optunaJobId = localStorage.getItem(OPTUNA_LS_KEY) || ''; +let _optunaPollStatus = ''; +let _optunaJobsRefreshing = false; +/** 호가8방 표 재렌더 방지용 — 폴링마다 innerHTML 갈아끼우면 스크롤이 위로 튐 */ +let _optunaPostFp = ''; +/** 프로그레스 갱신 체감용 2초 · 표는 fingerprint로 안 다시 그림 */ +const OPTUNA_POLL_MS_BUSY = 2000; +const OPTUNA_POLL_MS_IDLE = 2000; + +async function optunaRefreshJobsIfNeeded(force) { + if (_optunaJobsRefreshing && !force) return; + _optunaJobsRefreshing = true; + try { + await optunaRefreshJobs(); + } finally { + _optunaJobsRefreshing = false; + } +} function optunaGoTab() { - const el = document.querySelector('[data-tab="optuna"]'); - if (el) el.click(); + btShowTab('optuna'); } function optunaOnTabShow() { @@ -7669,22 +7716,69 @@ function optunaRenderCompare(sum) { if (gatesEl) { const g = sum && sum.stable_gates; if (g) { - gatesEl.textContent = + let t = `안정점수 = 일평균PnL − λ×일표준편차 (원). 높을수록 좋음 · 만점 없음. ` + `게이트: 손실일≤${g.max_losing_days} · 최악일≥${Number(g.min_worst_day_pnl).toLocaleString()} · ` + `활성일≥${g.min_active_days} · λ=${g.stability_lambda}`; + if (g.fallback_rank_only || g.fallback_note) { + t += ` · ${g.fallback_note || '게이트 0건 → 점수순 폴백(마이너스 포함)'}`; + } else if (g.n_gate_pass != null) { + t += ` · 게이트통과 ${g.n_gate_pass}건`; + } + gatesEl.innerHTML = t; } else if (sum) { gatesEl.textContent = '안정점수 = 일평균PnL − λ×일표준편차(원). 높을수록 좋음 · 만점 없음. (구 JSON은 게이트 미기록)'; } else { gatesEl.textContent = '—'; } } + optunaRenderModeCombo(sum); optunaRenderTrailRec(sum); optunaRenderPostprocess(sum); optunaRenderOverfitBadges(sum); optunaRenderOverfit(sum); } +function optunaRenderModeCombo(sum) { + const el = $('opt_mode_combo_body'); + if (!el) return; + const m = sum && sum.mode_combo_summary; + if (!m || !m.ok) { + const note = (sum && sum.mode_combo_note) || ''; + el.innerHTML = sum + ? (`mode_combo 실측 없음` + + (note ? `
${note}
` : '') + + `
`) + : '완료 후 표시'; + return; + } + const pnl = Number(m.total_pnl); + const pnlCls = Number.isFinite(pnl) ? (pnl > 0 ? 'text-pnl-pos' : (pnl < 0 ? 'text-pnl-neg' : '')) : ''; + const pnlStr = Number.isFinite(pnl) + ? ((pnl > 0 ? '+' : '') + Math.trunc(pnl).toLocaleString() + '원') + : '—'; + const wr = m.win_rate != null ? Number(m.win_rate) : NaN; + const wrCls = Number.isFinite(wr) ? (wr >= 40 ? 'text-pnl-pos' : 'text-pnl-neg') : ''; + const vs = m.vs_best || {}; + const vsLine = (vs.best_pnl != null && vs.mode_pnl != null) + ? ` · vs학습1위 PnL ${Number(vs.mode_pnl).toLocaleString()} / ${Number(vs.best_pnl).toLocaleString()}` + : ''; + el.innerHTML = + `
` + + `Top${m.top_n != null ? m.top_n : 'N'} 축최빈 조립` + + (m.pool_size != null ? ` (pool ${m.pool_size})` : '') + + ` · 거래 ${optunaFmtNum(m.total_trades, 0)}` + + ` · 승률 ${Number.isFinite(wr) ? wr.toFixed(1) + '%' : '—'}` + + ` · PF ${optunaFmtNum(m.pf, 2)}` + + ` · PnL ${pnlStr}` + + `${vsLine}
` + + (m.note ? `
${m.note}
` : '') + + `
` + + `` + + ` ` + + `
`; +} + function optunaRenderOverfit(sum) { const scoreEl = $('opt_overfit_score'); const facTb = $('opt_overfit_factors_tbody'); @@ -7795,10 +7889,9 @@ function optunaStableScoreCell(r) { function optunaPostBusy(job) { if (!job) return false; - const post = job.postprocess || {}; const rerun = job.postprocess_rerun || {}; if (String(rerun.status || '') === 'running') return true; - if (post.ready) return false; + if (job.status === 'done' || job.status === 'error') return false; if (job.phase === 'postprocess') return true; const prog = job.progress || {}; return job.status === 'running' && Number(prog.pct || 0) >= 99.9; @@ -7808,7 +7901,10 @@ function optunaRankTableRow(r, source) { const rank = r.rank || 1; const src = source; const busy = optunaPostBusy(window._optunaLastJob); - const detailBtn = (src === 'learn') + 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 ? '' @@ -7871,6 +7967,23 @@ function optunaRenderOverfitBadges(sum) { set('opt_overfit_badge_mode', modeRow); } +/** 호가8방 UI: 승률 색 기준(사후게이트 WR와 맞춤). PnL은 부호 색. */ +const OPTUNA_OB8_WR_GOOD_PCT = 40; + +function _optunaOb8WrSpan(wr) { + if (!Number.isFinite(wr)) return ''; + const cls = wr >= OPTUNA_OB8_WR_GOOD_PCT ? 'text-pnl-pos' : 'text-pnl-neg'; + return `승률 ${wr.toFixed(1)}%`; +} + +function _optunaOb8PnlSpan(pnl) { + if (pnl == null || !Number.isFinite(Number(pnl))) return ''; + const n = Math.trunc(Number(pnl)); + const cls = n > 0 ? 'text-pnl-pos' : (n < 0 ? 'text-pnl-neg' : ''); + const sign = n > 0 ? '+' : ''; + return `${sign}${n.toLocaleString()}원`; +} + function _optunaAxisStatsHtml(ax, recHint) { if (!ax || !ax.ok) { const r = (ax && ax.reason) || (recHint && recHint.reason) || '없음'; @@ -7909,23 +8022,108 @@ function _optunaAxisStatsHtml(ax, recHint) { recBitsP.push(`sub=${p.whipsaw_subbar_sec} · lb=${p.whipsaw_lookback_sec} · dip=${p.whipsaw_dip_pct}`); } const recLine = recBitsP.join(' · '); - const wrCls = (Number.isFinite(wr) && Number.isFinite(origWr) && wr + 0.05 < origWr) - ? 'text-warning' : 'text-muted'; + const wrDrop = (Number.isFinite(wr) && Number.isFinite(origWr) && wr + 0.05 < origWr); const recBits = []; if (cnt != null) recBits.push(cnt + '건'); - if (Number.isFinite(wr)) recBits.push('승률 ' + wr.toFixed(1) + '%'); - if (pnl != null) recBits.push(Number(pnl).toLocaleString() + '원'); + if (Number.isFinite(wr)) recBits.push(_optunaOb8WrSpan(wr)); + if (pnl != null) recBits.push(_optunaOb8PnlSpan(pnl)); const origBits = []; if (os.count != null && os.count !== cnt) origBits.push(os.count + '건'); - if (Number.isFinite(origWr)) origBits.push('승률 ' + origWr.toFixed(1) + '%'); - if (origPnl != null && origPnl !== pnl) origBits.push(Number(origPnl).toLocaleString() + '원'); - return `${recLine || '추천있음'}` - + (recBits.length ? `
필터후 ${recBits.join(' · ')}
` : '') - + (origBits.length ? `
필터전 ${origBits.join(' · ')}
` : ''); + if (Number.isFinite(origWr)) origBits.push(_optunaOb8WrSpan(origWr)); + if (origPnl != null && origPnl !== pnl) origBits.push(_optunaOb8PnlSpan(origPnl)); + const dropHint = wrDrop ? ' (필터후 승률↓)' : ''; + return `
${recLine || '추천있음'}` + + (recBits.length ? `
필터후 ${recBits.join(' · ')}${dropHint}
` : '') + + (origBits.length ? `
필터전 ${origBits.join(' · ')}
` : '') + + `
`; } -function _optunaObComboTable(ob, canExitStop, rowSrc, rowRk, isLive) { +function optunaOb8wayState(ob) { const combos = ob && ob.combos; + const hasCombos = !!(combos && typeof combos === 'object' && !Array.isArray(combos) && Object.keys(combos).length); + const reason = String((ob && ob.reason) || '').trim(); + const tc = (ob && ob.trade_count != null) ? Number(ob.trade_count) : null; + const fc = (ob && ob.fill_count != null) ? Number(ob.fill_count) : null; + if (hasCombos) return { kind: 'ok', reason, tc, fc, combos }; + if (reason && reason !== 'light_skip' && reason !== 'none') { + return { kind: 'failed', reason, tc, fc, combos: combos || {} }; + } + return { kind: 'legacy', reason, tc, fc, combos: combos || {} }; +} + +function optunaObReasonKo(st) { + if (!st || st.kind === 'ok') return ''; + if (st.reason === 'not_enough_trades') { + const n = Number.isFinite(st.tc) ? st.tc : '?'; + const fill = (Number.isFinite(st.fc) && st.fc > n) ? (` · 체결${st.fc}`) : ''; + return `호가스냅 ${n}건(<3)${fill} · 8방 TPE 생략`; + } + if (st.reason === 'no_orderbook_snapshots') return '호가 스냅샷 테이블 공백'; + if (st.reason === 'no_replay_fills') return '체결 재실행 없음'; + if (st.kind === 'legacy') return '구JSON(8방 키 없음)'; + return st.reason || '8방 미산출'; +} + +function optunaPool8wayKind(anchors) { + const pool = (anchors || []).filter((a) => a && (a.role === 'gated' || a.role === 'learn' || a.role === 'mode')); + if (!pool.length) return 'legacy'; + let anyOk = false; + let anyFailed = false; + pool.forEach((a) => { + const st = optunaOb8wayState(a.orderbook); + if (st.kind === 'ok') anyOk = true; + else if (st.kind === 'failed') anyFailed = true; + }); + if (anyOk) return 'ok'; + if (anyFailed) return 'failed'; + return 'legacy'; +} + +function optunaSyncRerunButton(sum, job) { + const btn = $('opt_btn_pp_rerun'); + const stEl = $('opt_pp_rerun_status'); + const topn = sum && sum.postprocess_topn; + const anchors = (topn && topn.postprocess_by_anchor) || []; + const kind = optunaPool8wayKind(anchors); + const ran = !!(topn && topn.run_ob_whipsaw); + const busy = optunaPostBusy(job); + const needWarn = kind !== 'ok'; + if (btn) { + btn.disabled = !!busy; + btn.classList.toggle('btn-warning', needWarn && !busy); + btn.classList.toggle('btn-outline-secondary', !(needWarn && !busy)); + } + if (!stEl) return; + const pp = job && job.postprocess_rerun; + const prog = job && job.postprocess; + if (pp && pp.status === 'running') { + stEl.textContent = (prog && prog.hint) || ('재실행 중… 로그 ' + (pp.log_path || '')); + return; + } + if (pp && pp.status === 'error') { + stEl.textContent = '재실행 실패 · ' + (pp.error || ''); + return; + } + if (kind === 'legacy' && (!ran || !anchors.length)) { + stEl.textContent = '구JSON · 「이 잡 후처리 재실행」으로 8방을 채우세요'; + return; + } + if (kind === 'failed' || (kind === 'legacy' && ran)) { + const st0 = optunaOb8wayState(((anchors.find((a) => a.role === 'gated') || {}).orderbook)); + stEl.textContent = (optunaObReasonKo(st0) || '8방 미산출') + + ' · 재실행해도 호가스냅이 안 늘면 동일'; + return; + } + if (pp && pp.status === 'done') { + stEl.textContent = '재실행 완료 · ' + (pp.log_path || ''); + return; + } + if (prog && prog.hint) stEl.textContent = prog.hint; +} + +function _optunaObComboTable(ob, canExitStop, rowSrc, rowRk, isLive, row) { + const st = optunaOb8wayState(ob); + const combos = st.combos; const rows = canExitStop ? [ ['base', '000 타점만(호가OFF)'], @@ -7938,28 +8136,54 @@ function _optunaObComboTable(ob, canExitStop, rowSrc, rowRk, isLive) { ['exs', '111 전부'], ] : [['base', '000 타점만(호가OFF)'], ['e', '100 진입만']]; - const hasCombos = combos && typeof combos === 'object' && Object.keys(combos).length; + const hasCombos = st.kind === 'ok'; const btn = (cid, lab) => { if (isLive || !rowSrc) return '참고'; const c = hasCombos ? (combos[cid] || {}) : null; const disabled = (cid !== 'base' && hasCombos && !(c && c.ok)); if (disabled) return ``; + if (cid !== 'base' && !hasCombos) { + return ``; + } return ``; }; const trs = rows.map(([id, lab]) => { const c = hasCombos ? (combos[id] || {}) : null; - const nt = (c && c.n_trials != null) ? c.n_trials : (hasCombos ? '—' : '구JSON'); - const stats = hasCombos - ? _optunaAxisStatsHtml(c || { ok: false, reason: '없음' }, ob) - : '후처리 재실행 필요'; + let nt; + let stats; + if (hasCombos) { + nt = (c && c.n_trials != null) ? c.n_trials : '—'; + stats = _optunaAxisStatsHtml(c || { ok: false, reason: '없음' }, ob); + } else if (id === 'base' && row) { + nt = st.kind === 'legacy' ? '구JSON' : '—'; + const bits = []; + if (row.total_trades != null) bits.push(row.total_trades + '건'); + if (row.win_rate != null) bits.push(_optunaOb8WrSpan(Number(row.win_rate))); + if (row.total_pnl != null) bits.push(_optunaOb8PnlSpan(row.total_pnl)); + const why = optunaObReasonKo(st); + stats = (bits.length ? `
${bits.join(' · ')}
` : '') + + (why ? `
${why}
` : '8방 미산출'); + } else { + nt = st.kind === 'legacy' ? '구JSON' : '—'; + stats = `${optunaObReasonKo(st) || (st.kind === 'legacy' ? '후처리 재실행 필요' : '8방 미산출')}`; + } return `${lab}${nt}${stats}${btn(id, '이 방 적용')}`; }).join(''); - return `
- - + const foot = st.kind === 'ok' + ? '8=2³. 휩쏘는 아래 별도.' + : (st.kind === 'legacy' + ? '구JSON · 「이 잡 후처리 재실행」으로 8방을 채우세요.' + : `${optunaObReasonKo(st)} · 재실행해도 호가스냅이 안 늘면 동일.`); + const rerunBtn = (st.kind !== 'ok' && !isLive) + ? '
' + : ''; + return `
+
호가 8방 (진입×익절×손절)trial성적적용
+ ${trs}
호가 8방 (진입×익절×손절)trial성적적용
-
8=2³. 휩쏘는 아래 별도. 구JSON이면 「후처리 재실행」 후 방별 숫자·적용이 채워집니다.
+
${foot}
+ ${rerunBtn}
`; } @@ -7968,43 +8192,65 @@ function _optunaBaseCell(a) { const wr = a.win_rate != null ? Number(a.win_rate) : NaN; const bits = []; if (a.total_trades != null) bits.push(a.total_trades + '건'); - if (Number.isFinite(wr)) bits.push('승률 ' + wr.toFixed(1) + '%'); - if (pnl != null) bits.push(Number(pnl).toLocaleString() + '원'); + if (Number.isFinite(wr)) bits.push(_optunaOb8WrSpan(wr)); + if (pnl != null) bits.push(_optunaOb8PnlSpan(pnl)); return `trial ${a.optuna_trial_number != null ? '#' + a.optuna_trial_number : '—'}` - + (bits.length ? `
${bits.join(' · ')}
` : ''); + + (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 optunaPostprocessFingerprint(sum) { + const topn = (sum && sum.postprocess_topn) || {}; + const anchors = topn.postprocess_by_anchor || []; + const cons = topn.postprocess_consensus || {}; + const cc = cons.combo || {}; + const sel = window._optunaPostSel || {}; + return [ + sel.source || '', + sel.rank || '', + anchors.map((a) => `${a.id}:${(a.orderbook && a.orderbook.ok) ? 1 : 0}`).join(','), + cc.combo_id || '', + cc.ok ? 1 : 0, + topn.apply_overfit_pct != null ? topn.apply_overfit_pct : '', + topn.run_ob_whipsaw ? 1 : 0, + ].join('|'); +} + function optunaRenderPostprocess(sum) { const cards = $('opt_postprocess_cards'); const consEl = $('opt_postprocess_consensus'); - const stEl = $('opt_pp_rerun_status'); const job = window._optunaLastJob || null; - if (stEl) { - const pp = job && job.postprocess_rerun; - const prog = job && job.postprocess; - if (pp && pp.status === 'running') { - stEl.textContent = (prog && prog.hint) || ('재실행 중… 로그 ' + (pp.log_path || '')); - } else if (pp && pp.status === 'done') { - stEl.textContent = '재실행 완료 · ' + (pp.log_path || ''); - } else if (pp && pp.status === 'error') { - stEl.textContent = '재실행 실패 · ' + (pp.error || ''); - } else if (prog && prog.hint) { - stEl.textContent = prog.hint; - } - } - if (!cards) return; + const finish = () => optunaSyncRerunButton(sum, job); + if (!cards) { finish(); return; } if (!sum) { - cards.innerHTML = '
사후합격 또는 안정 Top5에서 「상세」를 누르면 이 표가 바뀝니다.
'; + 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) || {}; @@ -8027,31 +8273,71 @@ function optunaRenderPostprocess(sum) { 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, 실매 제외)' - + `
호가: 8방 미산출(구JSON) · 진입 ${ce.ok ? '있음' : '없음'} · 익절 ${cx.ok ? '있음' : '없음'} · 손절 ${cs.ok ? '있음' : '없음'}` + + `
${head} · 진입 ${ce.ok ? '있음' : '없음'} · 익절 ${cx.ok ? '있음' : '없음'} · 손절 ${cs.ok ? '있음' : '없음'}` + ` · 휩쏘: ${cws.ok ? '있음' : '없음'} · 트레일: ${ctr.ok ? ('ARM ' + Number(ctr.arm_krw || 0).toLocaleString()) : '없음'}
` - + (cons.note ? `
${cons.note}
` : ''); + + (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). 「이 잡 후처리 재실행」으로 축별 숫자를 채우세요.
'; + cards.innerHTML = '
TopN 후처리 없음(구 JSON). 「이 잡 후처리 재실행」으로 축별 숫자를 채우세요.
' + + '
'; + finish(); return; } const sel = window._optunaPostSel || { source: 'gated', rank: 1 }; - const src = sel.source || 'gated'; - const rk = sel.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; @@ -8061,12 +8347,24 @@ function optunaRenderPostprocess(sum) { const verd = (ofRow && (ofRow.overfit_verdict_ui || ofRow.overfit_verdict)) || (topn && (topn.apply_overfit_verdict_ui || topn.apply_overfit_verdict)) || ''; if (!a) { - cards.innerHTML = '
이 순위 후처리 앵커 없음. Top5 「상세」를 누르세요.
'; + 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' : 'gated')); + 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 = () => { @@ -8091,7 +8389,7 @@ function optunaRenderPostprocess(sum) {
${title}
과적합%=추정. ${row.note || ''} · 호가 경우의수=진입×익절×손절=8 · 휩쏘는 8방 밖
차트 타점: ${_optunaBaseCell(rowForBase)} · ${baseBtn()}
- ${_optunaObComboTable(ob, canExitStop, rowSrc, rowRk, isLive)} + ${_optunaObComboTable(ob, canExitStop, rowSrc, rowRk, isLive, row)}
@@ -8106,13 +8404,37 @@ function optunaRenderPostprocess(sum) { `; }; - cards.innerHTML = renderOne(a, { live: false }) + (live ? renderOne(live, { live: true }) : ''); + 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 optunaRenderTop5(sum) { @@ -8136,7 +8458,7 @@ function optunaRenderTop5Stable(sum) { 'opt_top5_stable_tbody', (sum && sum.top5_stable) || [], 'stable', - 'stable 후보 없음 (구 JSON은 재실행 필요 / 게이트 미충족)', + 'stable 후보 없음 (일별 필드 없는 구 JSON · 재실행 필요)', ); } @@ -8220,6 +8542,10 @@ async function optunaShowCandidate(source, rank) { function optunaRenderJob(job) { if (!job) return; + if (job.job_id) { + _optunaJobId = job.job_id; + try { localStorage.setItem(OPTUNA_LS_KEY, job.job_id); } catch (e) { /* ignore */ } + } const prog = job.progress || {}; const pct = Number(prog.pct || 0); const fill = $('opt_tab_fill'); @@ -8228,7 +8554,13 @@ function optunaRenderJob(job) { fill.classList.toggle('is-done', job.status === 'done'); fill.classList.toggle('is-error', job.status === 'error'); } - if ($('opt_st_status')) $('opt_st_status').textContent = job.status || '—'; + if ($('opt_st_status')) { + const stEl = $('opt_st_status'); + stEl.textContent = job.status || '—'; + stEl.style.cursor = 'pointer'; + stEl.title = '클릭하면 다른 PC 실행 명령'; + stEl.onclick = function () { optunaOpenJoinCmd(job.job_id); }; + } if ($('opt_st_job')) $('opt_st_job').textContent = job.job_id || '—'; if ($('opt_st_study')) { $('opt_st_study').textContent = job.active_study_name || job.study_name || '—'; @@ -8239,10 +8571,17 @@ function optunaRenderJob(job) { job.current_strategy, job.current_entry_mode, ].filter(Boolean).join('/'); + const seqBits = (prog.seq_step && prog.seq_steps) + ? `단계 ${prog.seq_step}/${prog.seq_steps}` + : ''; $('opt_st_trials').textContent = `${prog.trials_done ?? '—'} / ${prog.trials_total ?? job.trials ?? '—'} (${pct}%)` + + (seqBits ? ` · ${seqBits}` : '') + (nowBits ? ` · now=${nowBits}` : '') + - (met ? ` · ${met}` : ''); + (met ? ` · ${met}` : '') + + ((job.status === 'running' && Number(prog.trials_done || 0) === 0) + ? ' · 준비(캔들·틱 로드) — Top5는 trial 시작 후' + : ''); } const post = job.postprocess || {}; const postFill = $('opt_post_fill'); @@ -8500,13 +8839,16 @@ async function optunaApplyUpto(source, rank, upto) { } async function optunaRerunPostprocess() { - if (!_optunaJobId) { alert('선택된 job 없음'); return; } - if (!confirm('이 잡 JSON에 후처리(진입/익절/손절/휩쏘)를 다시 붙일까요?\n실매 DB는 안 바뀝니다. 백그라운드 실행.')) return; + const jid = _optunaJobId || (window._optunaLastJob && window._optunaLastJob.job_id) || ''; + if (!jid) { alert('선택된 job 없음 — 최근 잡 목록에서 해당 잡을 누르세요'); return; } + _optunaJobId = jid; + try { localStorage.setItem(OPTUNA_LS_KEY, jid); } catch (e) { /* ignore */ } + if (!confirm('이 잡 JSON에 후처리(진입/익절/손절/휩쏘)를 다시 붙일까요?\n실매 DB는 안 바뀝니다. 백그라운드 실행.\n호가스냅이 3건 미만이면 8방 숫자는 그대로일 수 있습니다.')) return; try { const r = await fetch('/api/optuna/postprocess/rerun', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ job_id: _optunaJobId }), + body: JSON.stringify({ job_id: jid }), }); const j = await r.json(); if (!j.ok) { alert('❌ ' + (j.error || '실패')); return; } @@ -8528,17 +8870,26 @@ async function optunaPollOnce() { if (j.running && j.running[0]) { _optunaJobId = j.running[0].job_id; localStorage.setItem(OPTUNA_LS_KEY, _optunaJobId); + const st = j.running[0].status || 'running'; + const prev = _optunaPollStatus; + _optunaPollStatus = st; optunaRenderJob(j.running[0]); + await optunaRefreshJobsIfNeeded(prev !== st); return; } if (j.latest) { - optunaRenderJob(j.latest); - if (j.latest.status === 'running') { + if (j.latest.job_id) { _optunaJobId = j.latest.job_id; localStorage.setItem(OPTUNA_LS_KEY, _optunaJobId); - } else if (j.latest.status === 'done' || j.latest.status === 'error') { + } + const st = j.latest.status || ''; + const prev = _optunaPollStatus; + _optunaPollStatus = st; + optunaRenderJob(j.latest); + if (j.latest.status === 'done' || j.latest.status === 'error') { optunaSetNav(j.latest); } + if (prev !== st || st === 'running') await optunaRefreshJobsIfNeeded(prev !== st && (st === 'done' || st === 'error')); } else if (j.external) { optunaSetNav({ label: 'CLI Optuna', @@ -8562,17 +8913,39 @@ async function optunaPollOnce() { localStorage.removeItem(OPTUNA_LS_KEY); return; } + const st = j.job.status || ''; + const prev = _optunaPollStatus; + const finishedNow = (st === 'done' || st === 'error') && prev === 'running'; + const busy = st === 'running' || optunaPostBusy(j.job); + _optunaPollStatus = st; optunaRenderJob(j.job); - if (j.job.status === 'done' || j.job.status === 'error') { - // 완료 후에도 상단 바는 잠시 유지 (클릭해 결과 확인) + // 러닝 중·상태 변화·완료 직후 → 잡 목록도 자동 갱신 (수동 새로고침 불필요) + if (busy || finishedNow || prev !== st) { + await optunaRefreshJobsIfNeeded(finishedNow); } } catch (e) { /* ignore */ } } +function optunaPollDelayMs() { + const job = window._optunaLastJob; + if (!job) return OPTUNA_POLL_MS_IDLE; + if (job.status === 'running' || optunaPostBusy(job)) return OPTUNA_POLL_MS_BUSY; + return OPTUNA_POLL_MS_IDLE; +} + function optunaStartPolling() { - if (_optunaPollTimer) clearInterval(_optunaPollTimer); - optunaPollOnce(); - _optunaPollTimer = setInterval(optunaPollOnce, 3000); + if (_optunaPollTimer) { + clearTimeout(_optunaPollTimer); + clearInterval(_optunaPollTimer); + _optunaPollTimer = null; + } + const tick = async () => { + try { + await optunaPollOnce(); + } catch (e) { /* ignore */ } + _optunaPollTimer = setTimeout(tick, optunaPollDelayMs()); + }; + tick(); } function optunaSelectedStrategies() { @@ -8621,6 +8994,8 @@ async function optunaStart() { const start = $('opt_start')?.value; const end = $('opt_end')?.value; const trials = parseInt($('opt_trials')?.value || '200', 10); + let studyTrials = parseInt($('opt_study_trials')?.value, 10); + if (!Number.isFinite(studyTrials) || studyTrials <= 0) studyTrials = null; const mode = $('opt_mode')?.value || 'tpe'; const univSrc = univHistorySourceParam('opt_univ_history_source'); const candleSrc = $('opt_candle_source')?.value || ''; @@ -8638,13 +9013,16 @@ async function optunaStart() { const slLabel = strategies.includes('breakout') ? `\n돌파손절=${slModes.join('+')}` + (slModes.length >= 2 ? ' (순차 2스터디)' : '') : ''; - if (!confirm(`Optuna 시작?\n${how}: ${strategies.join(', ')}\n${start}~${end} trials=${trials}\n이력소스=${srcLabel}${emLabel}${slLabel}\n(DB 미적용)`)) return; + if (!confirm(`Optuna 시작?\n${how}: ${strategies.join(', ')}\n${start}~${end} trials=${trials}` + + (studyTrials ? `\n스터디 총 횟수=${studyTrials}` : '') + + `\n이력소스=${srcLabel}${emLabel}${slLabel}\n(DB 미적용)`)) return; try { const r = await fetch('/api/optuna/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ strategies, start, end, trials, mode, + study_trials: studyTrials || undefined, universe_history_source: univSrc, candle_source: candleSrc, tick_source: tickSrc, @@ -8665,6 +9043,44 @@ async function optunaStart() { } } +async function optunaContinue(jobId) { + const jid = jobId || _optunaJobId; + if (!jid) { alert('잡 없음'); return; } + if (!confirm('같은 study 이름으로 남은 횟수를 웹에서 이어 돌릴까요?\n(시작 버튼의 새 이름은 쓰지 않습니다)')) return; + try { + const r = await fetch('/api/optuna/continue', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ job_id: jid }), + }); + const j = await r.json(); + if (!j.ok) { alert('❌ ' + (j.error || '실패')); return; } + _optunaJobId = j.job.job_id; + localStorage.setItem(OPTUNA_LS_KEY, _optunaJobId); + optunaRenderJob(j.job); + optunaStartPolling(); + optunaRefreshJobs(); + } catch (e) { alert('오류: ' + e); } +} + +async function optunaConfirmStudy(jobId) { + const jid = jobId || _optunaJobId; + if (!jid) { alert('잡 없음'); return; } + if (!confirm('지금 완료 수로 목표를 줄이고 후처리를 돌릴까요?\n윈도우/추가 웹 이어가기는 포기합니다. 실매 DB는 안 바뀝니다.')) return; + try { + const r = await fetch('/api/optuna/confirm', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ job_id: jid }), + }); + const j = await r.json(); + if (!j.ok) { alert('❌ ' + (j.error || '실패')); return; } + alert('확정 · 후처리 시작\n' + (j.log_path || j.note || '')); + optunaRefreshJobs(); + if (_optunaJobId) optunaPollOnce(); + } catch (e) { alert('오류: ' + e); } +} + async function optunaStop() { if (!_optunaJobId) return; if (!confirm('실행 중 Optuna를 중지할까요?')) return; @@ -8693,15 +9109,27 @@ async function optunaRefreshJobs() { const jid = job.job_id; const label = job.label || job.strategy || ''; const sel = jid === _optunaJobId ? 'outline:1px solid var(--accent)' : ''; + const st = String(job.status || ''); + const stCell = (st === 'running') + ? `` + : st; + const extraBtns = (st === 'done' && job.can_continue) + ? `` + + `` + : ''; return ` - - + + - + `; }).join(''); } catch (e) { @@ -8710,6 +9138,92 @@ async function optunaRefreshJobs() { } } +function optunaCloseJoinCmd() { + const el = $('opt_join_overlay'); + if (el) el.classList.remove('is-open'); +} + +function optunaFillJoinOverlay(job) { + if (!job) return; + if ($('opt_join_hint')) $('opt_join_hint').textContent = job.join_hint || ''; + 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')) $('opt_web_cmd').textContent = job.web_cmd || job.cmd || '—'; + const box = $('opt_join_all'); + if (box) { + const all = job.join_cmds_all || []; + if (!all.length) { + box.innerHTML = ''; + return; + } + window._optunaJoinAll = all; + box.innerHTML = '
순차 스터디별 (이미 START 된 것만)
' + + all.map((row, i) => { + const lab = [row.strategy, row.extra].filter(Boolean).join('/'); + return `
+ ${i + 1}. ${lab} + + + + +
+

+        
`;
+      }).join('');
+    all.forEach((row, i) => {
+      const pre = document.getElementById('opt_join_all_' + i);
+      if (pre) pre.textContent = row.cmd || '';
+      const ps = document.getElementById('opt_join_all_ps_' + i);
+      if (ps) ps.textContent = row.cmd_ps || '';
+    });
+  }
+}
+
+function optunaCopyText(text) {
+  const t = String(text || '');
+  if (!t) return;
+  if (navigator.clipboard && navigator.clipboard.writeText) {
+    navigator.clipboard.writeText(t).then(() => {}, () => { prompt('복사:', t); });
+  } else {
+    prompt('복사:', t);
+  }
+}
+
+function optunaCopyJoinAll(i, which) {
+  const row = (window._optunaJoinAll || [])[i];
+  if (!row) return;
+  optunaCopyText(which === 'ps' ? (row.cmd_ps || '') : (row.cmd || ''));
+}
+
+function optunaCopyJoin(which) {
+  let el = $('opt_join_cmd');
+  if (which === 'web') el = $('opt_web_cmd');
+  if (which === 'ps') el = $('opt_join_cmd_ps');
+  optunaCopyText(el ? el.textContent : '');
+}
+
+async function optunaOpenJoinCmd(jobId) {
+  const jid = jobId || _optunaJobId || (window._optunaLastJob && window._optunaLastJob.job_id);
+  const ov = $('opt_join_overlay');
+  if (!ov) return;
+  if (!jid) { alert('선택된 잡 없음'); return; }
+  ov.classList.add('is-open');
+  if (window._optunaLastJob && window._optunaLastJob.job_id === jid) {
+    optunaFillJoinOverlay(window._optunaLastJob);
+  }
+  try {
+    const r = await fetch('/api/optuna/status/' + encodeURIComponent(jid));
+    const j = await r.json();
+    if (j.ok && j.job) optunaFillJoinOverlay(j.job);
+    else if (j.error) alert(j.error);
+  } catch (e) {
+    alert('명령 로드 실패: ' + e);
+  }
+}
+
 function optunaWatch(jobId) {
   _optunaJobId = jobId;
   localStorage.setItem(OPTUNA_LS_KEY, jobId);
@@ -8874,3 +9388,4 @@ function kisMockBoot() {
 // ────────────────────────────────────────────
 optunaBoot();
 kisMockBoot();
+btRestoreTab();
diff --git a/templates/backtest.html b/templates/backtest.html
index 9b81c8a..5b0380f 100644
--- a/templates/backtest.html
+++ b/templates/backtest.html
@@ -2745,6 +2745,10 @@
             
+          
+ + +
@@ -2765,7 +2769,7 @@
8방 밖성적적용
${jid} ${label} ${job.start || ''}~${job.end || ''}${job.status || ''}${job.phase === 'postprocess' ? ' ·후처리' : ''}${prog.trials_done ?? '—'}/${prog.trials_total ?? job.trials ?? '—'}${job.postprocess && job.postprocess.pct != null && job.phase === 'postprocess' ? ' ·후 ' + job.postprocess.pct + '%' : ''}${stCell}${job.phase === 'postprocess' ? ' ·후처리' : ''}${job.leftover_note ? ' ·' + job.leftover_note : ''}${prog.trials_done ?? '—'}/${prog.trials_total ?? job.trials ?? '—'}${job.study_trials ? ' ·목표 ' + (job.n_complete ?? '—') + '/' + job.study_trials : ''}${job.postprocess && job.postprocess.pct != null && job.phase === 'postprocess' ? ' ·후 ' + job.postprocess.pct + '%' : ''} ${job.started_at || ''} ${job.finished_at || '—'} + + ${extraBtns} + +
@@ -2781,7 +2785,7 @@
-
사후합격 Top5 results_gated
+
사후합격 Top10 results_gated
@@ -3777,6 +3781,10 @@ +
+ + +
@@ -3834,7 +3842,9 @@
후처리 — trial 끝난 뒤 시작
-
상태
+
상태 + +
study
trial
@@ -3913,7 +3923,7 @@
-
학습 Top5 results · Optuna 목적함수 1위 풀 · 진행바 best=여기 1위
+
학습 Top10 results · Optuna 목적함수 1위 풀 · 진행바 best=여기 1위 · OPTUNA_POST_TOP_N
@@ -3934,7 +3944,7 @@
-
사후합격 Top5 results_gated · 「상세」=아래 후처리 표(8방 호가)
+
사후합격 Top10 results_gated · 「상세」=아래 후처리 표(8방 호가)
@@ -3955,7 +3965,12 @@
-
안정 Top5 results_stable · 「상세」=아래 후처리 표(8방 호가) · 손실일/최악일 게이트
+
mode_combo 축별 최빈 조립 · trial 번호 없음 · 탐색(WR/PF=0) TopN 합의 1회 실측
+
완료 후 표시
+
+ +
+
안정 Top10 results_stable · gated/플러스와 독립 · 게이트0이면 안정점수순 폴백
@@ -3976,16 +3991,16 @@
후처리 비교표 - (사후합격·안정 Top5 「상세」 · 열까지 적용=뒤쪽 축은 OFF · live 참고만) + (사후합격「상세」· gated 없으면 학습Top learn# 폴백 · mode/live)
- + - 후처리 바가 100%·「상세 가능」이 뜬 뒤에 「상세」를 누르세요. 진짜 구 JSON만 재실행. + 끝난 잡은 이 버튼이 켜집니다. 8방이 비면 누르세요(호가스냅 부족이면 재실행해도 동일).
합의: 완료 후 표시
-
-
사후합격 또는 안정 Top5에서 「상세」를 누르면 이 표가 바뀝니다.
+
+
사후합격 또는 안정 Top에서 「상세」를 누르면 이 표가 바뀝니다.
@@ -4027,6 +4042,34 @@ - +
+ +
+ + \ No newline at end of file