From 1387fbdf47cffd26224192650108b4a3ff1a9b38 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 28 Aug 2026 16:46:27 +0900 Subject: [PATCH] =?UTF-8?q?feat(=EC=98=B5=ED=88=AC=EB=82=98):=20score?= =?UTF-8?q?=C2=B7min=5Ftrades=C2=B7=ED=9B=84=EC=B2=98=EB=A6=AC=20=EC=9E=AC?= =?UTF-8?q?=ED=83=90=EC=83=89=20=EB=B0=8F=20=EC=9B=B9=20job=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PnL/(MDD+ADD) score·legacy 정렬·거래일×min_trades 게이트를 공통화한다. 후처리 ob_modes·study store·4전략 TPE 순차 스크립트와 문서를 갱신한다. Co-authored-by: Cursor --- docs/옵투나.md | 187 +++++++++---- docs/호가.md | 24 +- kis_trader/backtest/optuna_breakout.py | 24 +- kis_trader/backtest/optuna_common.py | 292 ++++++++++++++++++++- kis_trader/backtest/optuna_momentum.py | 34 ++- kis_trader/backtest/optuna_scalping.py | 46 ++-- kis_trader/backtest/optuna_study_store.py | 5 + kis_trader/backtest/optuna_web_jobs.py | 43 ++- kis_trader/backtest/param_search_optuna.py | 50 ++-- scripts/run_optuna_4strat_tpe_seq.sh | 14 +- 10 files changed, 583 insertions(+), 136 deletions(-) diff --git a/docs/옵투나.md b/docs/옵투나.md index a2e596b..f16442a 100644 --- a/docs/옵투나.md +++ b/docs/옵투나.md @@ -1,46 +1,79 @@ # Optuna 파라미터 탐색 및 호가 수급 최적화 구조 가이드 (옵투나.md) -이 문서는 백테스트 웹·CLI의 **Optuna 2단계**와, 호가 후처리 격자·적용 분리를 정리한다. -관련: `docs/호가.md` §8 · `kis_trader/backtest/optuna_postprocess_topn.py` · `optuna_orderbook_recommend.py` · `optuna_rerun_postprocess.py` +이 문서는 **현재 코드** 기준 Optuna TPE·호가 스터디 스위치·후처리를 정리한다. +관련: `docs/호가.md` §8 · `optuna_tpe_common.py` · `optuna_breakout_tpe_space.py` · `optuna_postprocess_topn.py` · `optuna_orderbook_recommend.py` -최종 갱신: 2026-08-15 +최종 갱신: **2026-08-28** (구 2026-08-15 「Stage1=호가 축 없음」서술은 **폐기** — 아래 §1이 진실) --- -## 1. 투-스테이지 +## 1. 현재 구조 (코드 진실 · 2026-08-28) + +### 1-0. 한 줄 + +| 구분 | 무엇을 하냐 | +|------|-------------| +| **본 TPE (Stage 1)** | 차트 축 + (기본) **진입 호가·휩쏘를 trial에서 같이 평가** | +| **사후 8방 (Stage 2)** | 본 TPE에 호가축이 **켜져 있으면 기본 스킵**. 구 타점-only 모드일 때만 자동 | + +`OPTUNA_TPE_INCLUDE_ORDERBOOK` 기본 **true** (`optuna_tpe_common.optuna_tpe_include_orderbook`). +→ 본 trial이 이미 호가를 쓰므로, 사후 8방으로 또 걸러 “성적 사기” 나지 않게 `_run_ob_whipsaw_full()` 이 **기본 OFF**. +강제만: `OPTUNA_POST_FORCE_OB_WHIPSAW=true`. + +구 문서의 「Stage1=캔들만 · 호가ON/OFF 넣지 않음」은 **옛 설계**. 지금은 기본이 반대다. ``` -Stage 1 캔들 TPE (param_search_optuna.py) - RSI·손익·트레일 등 차트 축. 호가 ON/OFF는 넣지 않음. +[기본 경로] + Stage 1 TPE — 차트 + 호가 ON/OFF(또는 돌파=스터디 고정) + 임계값 + (전략별) 휩쏘 + Stage 2 사후 8방 — INCLUDE_ORDERBOOK=true 이면 생략 (FORCE 만 예외) -Stage 2 후처리 (attach_topn_postprocess) - gated TopN(+mode/live) 체결을 호가 스냅으로 재생. - 진입 스프/잔량/매도벽 · EXIT_OB · STOP_OB · 휩쏘. +[구 타점-only] + OPTUNA_TPE_INCLUDE_ORDERBOOK=false + → Stage 1 차트만 · Stage 2 사후 8방 기본 ON ``` -Stage 2는 **이미 난 체결 + 근처 `ws_orderbook`/`ls_ws_orderbook`**. -실매 “호가 RAM 없음 → 필터 ON인데 통과” 는 `WS_ORDERBOOK_FILTER_REJECT_IF_EMPTY`(기본 true) + `WS_ORDERBOOK_FILTER_MAX_AGE_SEC` (B안, `docs/호가.md`). 1과목 차트 TPE와 별개. - -구 JSON만 Stage 2 다시: - -```bash -python3 -u kis_trader/backtest/optuna_rerun_postprocess.py \ - --result-json kis_trader/backtest/results/optuna_<전략>_tpe_.json -# --apply-best 없음. 실매 DB 안 바뀜. -``` - -웹 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) +## 1-1. 전략별 호가 ON/OFF 넣는 방식 (핵심) + +**문제:** 한 스터디에 호가 ON/OFF를 categorical로 섞으면, ON은 거래수가 줄고 TPE가 **OFF만 편애**하기 쉽다. +(거래수·PnL 랭킹이 OFF에 유리 → ON 임계값은 사실상 미탐색) + +| 전략 | 본 TPE 호가 | 방식 | 코드 | +|------|-------------|------|------| +| **돌파** | **스터디 스위치** | `--orderbook-filter off\|on` · 웹 `ob_modes` · **한 스터디에 ON/OFF 안 섞음** · ON 스터디만 스프/잔량/벽 축 | `optuna_breakout_tpe_space.py` | +| **꼬리** | trial **categorical** | `_orderbook_filter_enabled` [False,True] + 임계값 매 trial | `suggest_orderbook_entry_tpe` | +| **모멘텀** | 동일 categorical | 위와 같음 | `optuna_momentum_tpe_space.py` | +| **스캘프** | 동일 categorical | 위와 같음 | `optuna_scalping_tpe_space.py` | + +돌파 손절모드(fixed/atr)도 **스터디 스위치**(trial 축 아님). 웹은 손절×호가 **최대 4순차**. + +꼬리 진입모드(align / limit_atr)도 **스터디 고정·순차** (한 TPE에 categorical 혼입 없음) — §3. + +### 1-2. 분리 vs trial 수 늘리기 (결과 동일 여부) + +| | 한 스터디 N trial (ON/OFF 섞음) | 스터디 분리 (off N + on N) | +|--|-------------------------------|----------------------------| +| TPE 예산 | OFF에 몰림 | ON·OFF **각자** 탐색 | +| best | 대개 OFF 쪽 | OFF best / ON best **따로** 비교 | +| 동등성 | **≠** 분리 2×(N/2) | 의도적으로 다른 실험 | + +**400번 섞어 돌리기 ≠ off 200 + on 200.** +숫자만 늘려도 ON 공간(임계값)은 거의 안 볼 수 있다. +돌파처럼 **분리가 맞고**, 꼬리·모멘텀·스캘프도 **같은 편향**이 난다 (아직 categorical). + +권장: + +1. **돌파와 동일** — 전략별 `off` / `on` 스터디 스위치 (또는 웹 순차) +2. 또는 `OPTUNA_TPE_INCLUDE_ORDERBOOK=false` → 차트만 + **사후 8방** (구 Stage1/2 문서 경로) + +--- + +## 2. 진입 후처리 격자 (사후 8방 · Stage 2) `ensure_optuna_gate_env_defaults` → `OPTUNA_OB_*`. +**본 TPE 호가축 OFF일 때만** 기본 자동 실행 (§1-0). + `OPTUNA_OB_COMBO_TRIALS_SINGLE`(기본 150) / `DOUBLE`(200) / `TRIPLE`(250) = 8방 중 켜진 축 개수별 trial. 방 000은 TPE 없음. 모멘텀·돌파 합 150×3+200×3+250=1,300. 꼬리·스캘프는 진입 on/off만(150). 구 `OPTUNA_OB_AXIS_TRIALS`(500)는 축 독립 탐색용 잔여 키 — 8방 경로에서는 안 씀. @@ -57,40 +90,58 @@ python3 -u kis_trader/backtest/optuna_rerun_postprocess.py \ 결과는 `orderbook_filter_enabled=True` 고정(후처리가 “끌지”를 탐색하지 않음). apply 패치에 벽이 있으면 `{전략}_ORDERBOOK_ENTRY_ASK_MAX_MULT`. +구 JSON만 Stage 2 강제: + +```bash +# INCLUDE_ORDERBOOK=true 여도 강제하려면 +# OPTUNA_POST_FORCE_OB_WHIPSAW=true 또는 rerun 스크립트 경로 확인 +python3 -u kis_trader/backtest/optuna_rerun_postprocess.py \ + --result-json kis_trader/backtest/results/optuna_<전략>_tpe_.json +# --apply-best 없음. 실매 DB 안 바뀜. +``` + +**후처리 자동 실행 게이트** (`kis_study_trials` / `--study-trials`): +끝난 기준 = **시도 수** `COMPLETE + PRUNED + FAIL` ≥ 목표. +`tp_max < tp` 같은 pruned도 시도로 친다. COMPLETE만 세서 스킵하지 않음 (2026-08-21). + +백테/옵투나 호가·틱 **3차 LS** 나이 = `LIVE_FEED_FALLBACK_MAX_AGE_SEC`(기본 3초). +`trigger_snapshot_loader` / `BT_TICK_LS_THIRD_FALLBACK` — 실매 RAM 폴백과 동일 env. + --- ## 3. 꼬리 TPE 진입모드 (웹 체크) -엔진 실매 기본은 `TAIL_ENTRY_MODE` → **`limit_atr`** (`limit_entry_common.short_entry_mode`). -웹 꼬리 탭 셀렉트 기본도 limit_atr. **Optuna TPE는 예외: 진입을 탐색하지 않고 스터디마다 고정.** 예전 코드는 **`align` 하드코딩**. +엔진 실매 기본은 `TAIL_ENTRY_MODE` → **`limit_atr`**. +**Optuna TPE는 진입을 탐색하지 않고 스터디마다 고정.** 웹 Optuna 탭: **align** / **limit_atr** 체크. -- 기본: align만 (기존 TPE와 동일). +- 기본: align만. - 둘 다: **스터디 2개 순차** (한 TPE에 categorical 혼입 없음). `--apply-best` 없음. -CLI: `--entry-mode align|limit_atr` · 순차 스크립트 `TAIL_OPTUNA_ENTRY_MODES="align limit_atr"`. +CLI: `--entry-mode align|limit_atr` · 순차 `TAIL_OPTUNA_ENTRY_MODES="align limit_atr"`. --- ## 4. 실매 DB 적용 분리 -| 구분 | 차트 캔들 | 호가 후처리 | -|------|-----------|-------------| +| 구분 | 차트 캔들 | 호가 | +|------|-----------|------| | `--apply-best` (기본 미사용) | 차트 축 | 호가 자동 각인 아님 | -| 웹 Optuna 「이 방 적용」 | 차트 + **8방 중 하나** (`build_combo_env_patch`) | 켠 축만 ON, 끈 축 ENABLED=false | -| `whipsaw` | 차트 + **111방** + 휩쏘 | 휩쏘는 8방 밖 | +| 웹 「이 방 적용」(사후 8방이 있을 때) | 차트 + **8방 중 하나** | 켠 축만 ON | +| 웹 TopN 「적용」upto=full | **본 trial** 타점·익절·손절·**호가·휩쏘** 유지 | 사후 8방으로 OFF 덮어쓰기 **안 함** | +| `whipsaw` | 차트 + 휩쏘 | 8방 밖 | | 구 upto `entry/exit/stop` | 별칭 → `e` / `ex` / `exs` | 하위호환만 | -**8방 ≠ 표 5열.** 8 = 진입×익절×손절(2³). 예전 UI의 「기본·진입·익절·손절·휩쏘」5열 누적은 레거시. +**8방 ≠ 표 5열.** 8 = 진입×익절×손절(2³). 후처리 **재실행만** 하면 JSON만 갱신. DB는 안 바뀜. --- ## 5. 웹 표기 -`out_data["orderbook_recommend"]` · `postprocess_topn.postprocess_by_anchor` · `postprocess_consensus`. +`out_data["orderbook_recommend"]` · `postprocess_topn` · `tpe_includes_orderbook` · `post_run_ob_whipsaw`. -### 5-1. 앵커별 8방 표 +### 5-1. 앵커별 8방 표 (사후가 돌았을 때만) 각 Top5 「상세」 아래 **호가 8방** 표 + 방마다 「이 방 적용」. 휩쏘는 8방 밖 별도 행. | 방 | 켜진 축 | @@ -100,20 +151,60 @@ CLI: `--entry-mode align|limit_atr` · 순차 스크립트 `TAIL_OPTUNA_ENTRY_MO | 110 / 101 / 011 | 2축 스택 | | 111 | 진입+익절+손절 | -### 5-2. 합의(consensus) — 8방 기준 (2026-08-17~) -상세 위 **`합의(gated+mode)`** 한 줄: +본 TPE 호가축 ON이면 보통 이 표가 **비거나 생략**되고, Top 행에 **본 trial 호가 ON/OFF·임계**가 표시된다. + +### 5-2. 합의(consensus) — 8방 기준 +상세 위 **`합의(gated+mode)`** 한 줄 (사후 8방이 있을 때): - 사후합격 Top5 + mode_combo 앵커의 **8방 결과**만 모음 (stable·live 제외) -- **median PnL이 가장 높은 방 1개** 선택 (동일 방 내 파라미터는 median) -- 예: `호가 방: 011 익절+손절 · 5앵커 · PnL … · WR …` +- **median PnL이 가장 높은 방 1개** - 구 JSON(8방 없음)이면 축분리 median 폴백 + 「후처리 재실행 권장」 -휩쏘·다단트레일 합의는 예전과 동일(median/최빈). **8방에 포함되지 않음.** +### 5-3. 돌파 웹 스위치 +Optuna 탭: 돌파 **호가 off / on** 체크(스터디 스위치). 손절 fixed/atr × 호가 → 최대 4순차. +study 이름 예: `…_fixed_ob_off_…` / `…_atr_ob_on_…`. -### 5-3. 레거시(정리·미삭제) +### 5-4. 레거시(정리·미삭제) | 항목 | 상태 | |------|------| | `OPTUNA_OB_AXIS_TRIALS`(500) | 8방 미사용 잔여키 | | `entry`/`exit`/`stop` JSON 중첩 | 구 JSON·apply 호환용 | -| 모멘텀 `pattern_*`·시가컷 | 그리드/UI 잔여(실매 TRIGGER와 별축) | -| `docs/layered_exit_design.md` | 옛 설계서 → `docs/호가.md` 참고 | -| `remove/legacy_standalone_bots/` | 비활성 보관 | +| 모멘텀 `pattern_*`·시가컷 | 그리드/UI 잔여 | +| 「Stage1=호가 축 없음」문구 | **폐기** — §1 참고 | +| `docs/layered_exit_design.md` | → `docs/호가.md` | + +--- + +## 6. env 스위치 요약 + +| 키 | 기본 | 의미 | +|----|------|------| +| `OPTUNA_TPE_INCLUDE_ORDERBOOK` | true | 본 TPE에 호가 축(꼬리·모멘·스캘 categorical / 돌파는 별도 스위치) | +| `OPTUNA_TPE_INCLUDE_WHIPSAW` | true | 본 TPE 휩쏘 축 | +| `OPTUNA_POST_RUN_OB_WHIPSAW` | true | INCLUDE_ORDERBOOK=**false** 일 때 사후 8방 | +| `OPTUNA_POST_FORCE_OB_WHIPSAW` | false | INCLUDE_ORDERBOOK=true 여도 사후 8방 강제 | +| `LIVE_FEED_FALLBACK_MAX_AGE_SEC` | 3 | 실매·옵투나 호가/틱 3차 나이 | +| `BT_TICK_LS_THIRD_FALLBACK` | true | 옵투나 틱 3차 LS | +| `PARAM_SEARCH_OPTUNA_N_JOBS` | 1 | Optuna 병렬 **고정** 정수 (비율 아님) | +| `OPTUNA_MIN_TRADES_PER_DAY` | **2** | 모멘·돌파·스캘 등: `min_trades` = 거래일×2 | +| `OPTUNA_TAIL_MIN_TRADES` | **1** | **꼬리만** 탐색·gated 공통 min_trades (기간 무관) | +| `OPTUNA_SCORE_MDD_ADD` | **10000** | 새 score 분모 `MDD + ADD` | +| `OPTUNA_SCORE_MDD_FLOOR` | **10000** | (구) score 분모 `max(MDD, FLOOR)` — sort_by=score_legacy | +| `OPTUNA_SCORE_TRADE_SOFT_DAYS` | **2** | soft_n 기본 = 하루최소건×이 값 (기본 2×2=4). `OPTUNA_SCORE_TRADE_SOFT_N` 이 있으면 그쪽 우선 | + +### 6-1. min_trades · 일평균 · 승리식 (2026-08-28) + +- **웹 Optuna** `min_trades` = `resolve_optuna_min_trades` — **꼬리=1 고정**, 그 외 거래일×하루2. CLI `--min_trades` 직접 지정 시 유지. +- 표 **일평균** = `total_pnl ÷ 기간 거래일` (`period_daily_avg_pnl`). + +#### 승리식 (전 전략 공통 · 웹 셀렉트) + +| 화면 | `--sort-by` | 식 | +|------|-------------|-----| +| **수익·낙폭·표본 (기본)** | `score` | `(PnL / (MDD + ADD)) × √(min(거래수, soft_n) / soft_n)` | +| **(구) PnL/MDD하한** | `score_legacy` | `PnL / max(MDD, FLOOR)` — 전략별 `*_SCORE_MDD_FLOOR` 또는 `OPTUNA_SCORE_MDD_FLOOR` | +| 일평균 | `daily_avg` | `PnL ÷ 거래일수` | +| 총손익 | `pnl` | `PnL` | + +- **구 score** 는 웹 승리식 `(구) PnL/MDD하한` / CLI `--sort-by score_legacy` 로 선택. +- 웹에 승률 단독 승리식 없음 (CLI `win_rate`만). +- 이미 끝난 study는 옛 목적함수 → **새 study-name으로 다시** 돌려야 새 식이 적용됨. diff --git a/docs/호가.md b/docs/호가.md index aa59386..cac1650 100644 --- a/docs/호가.md +++ b/docs/호가.md @@ -458,7 +458,7 @@ OR 5장 채우는 시간: LS 틱동기면 체결 몇 번(초 단위) · 키움 ~ 5. 스냅 없이 OHLC로 호가 **지어내기** 금지. 6. 모의에 ORD_DVSN=13 강제 금지. 7. 필터 TTL과 저장 TTL을 **한 키로 묶지 말 것** (`FILTER_MAX_AGE` vs `TICK_MAX_AGE`). -8. 캔들 TPE 본체에 호가 ON/OFF를 넣지 말 것. 숫자 탐색은 **후처리**만 (§8). +8. Optuna 호가: **현재 기본은 본 TPE에 진입 호가 포함** (`OPTUNA_TPE_INCLUDE_ORDERBOOK`). 돌파는 스터디 스위치로 ON/OFF 분리. 상세 `docs/옵투나.md` §1. (구 “캔들 TPE에 호가 넣지 말 것”은 옛 권고.) 9. 거래대금·시총회전 **만**으로 손절 만들지 말 것. 10. 필터 ON인데 호가 사진이 없다고 **통과**시키지 말 것. REST로 메우지 말 것 (`REJECT_IF_EMPTY`). @@ -484,13 +484,19 @@ OR 5장 채우는 시간: LS 틱동기면 체결 몇 번(초 단위) · 키움 ~ --- -## 8. Optuna / TPE (2026-08-15) +## 8. Optuna / TPE (2026-08-28 · 상세는 `docs/옵투나.md`) -### 캔들 본체 TPE (Stage 1) -매수필터 ON/OFF · EXIT_OB · STOP_OB 를 **차트 trial 축에 넣지 않음**. -끈 구간과 켠 구간이 한 랭킹에 섞이면 순위가 호가 날씨에 흔들린다. +> **주의:** 아래 「캔들 본체에 호가 넣지 말 것」은 **옛 권고(2026-08-15)**. +> **현재 코드 기본**은 `OPTUNA_TPE_INCLUDE_ORDERBOOK=true` → 본 TPE에 진입 호가가 들어간다. +> 돌파만 **스터디 스위치**(off/on 분리). 꼬리·모멘·스캘은 아직 **같은 스터디 categorical**. +> 진실·분리 vs trial 수: **`docs/옵투나.md` §1**. -### 후처리 (Stage 2) — **이미 동작** +### 본 TPE (현재) +- 기본: 차트 + 호가(및 휩쏘)를 **trial에서 함께** 평가. +- 돌파: `--orderbook-filter off|on` · 한 스터디에 ON/OFF 안 섞음 (OFF 편애 방지). +- `INCLUDE_ORDERBOOK=true` 이면 사후 8방 **기본 생략** (성적 사기 방지). + +### 후처리 (Stage 2) — INCLUDE_ORDERBOOK=false 일 때 / FORCE 시 `optuna_postprocess_topn.py` → `optuna_orderbook_recommend.py`. 구 JSON만 다시: `python3 -u kis_trader/backtest/optuna_rerun_postprocess.py --result-json ` (`--apply-best` 아님. 실매 숫자는 안 바뀜.) @@ -501,11 +507,11 @@ OR 5장 채우는 시간: LS 틱동기면 체결 몇 번(초 단위) · 키움 ~ |----|------------------------------|-----------| | 스프레드 % | 0.1 ~ 8.0 step 0.1 | 모멘텀 운영 3.0 포함 | | 잔량비 | 0.05 ~ 1.5 step 0.05 | 0.58~1.0 포함 | -| 매도벽 배수 | 1 ~ 80 step 1 | 운영 3 / 스캘프 8. **예전 후처리는 이 축이 없어** 벽 탈락을 못 봄 | +| 매도벽 배수 | 1 ~ 80 step 1 | 운영 3 / 스캘프 8 | | lookback | `OPTUNA_OB_LOOKBACK_MIN` 기본 30분 | | -건수 하한: 잔여가 원본의 **30% 미만**이면 trial 무효 → 너무 센 컷은 점수에서 죽음. -후처리는 **이미 난 체결 + 근처 호가 재생**. 실매 “스냅 없음 통과” 구멍은 **모델링하지 않음** (그건 B안 필터 TTL). +건수 하한: 잔여가 원본의 **30% 미만**이면 trial 무효. +후처리는 **이미 난 체결 + 근처 호가 재생**. 실매 “스냅 없음 통과” 구멍은 **모델링하지 않음** (B안 필터 TTL). DB 반영은 사용자 명시 / `scripts/apply_optuna_ob_consensus.py` 만. 후처리 재실행 ≠ 적용. diff --git a/kis_trader/backtest/optuna_breakout.py b/kis_trader/backtest/optuna_breakout.py index e3a5342..15d7b04 100644 --- a/kis_trader/backtest/optuna_breakout.py +++ b/kis_trader/backtest/optuna_breakout.py @@ -385,7 +385,7 @@ def run_breakout_optuna( min_trades: int, min_win_rate: float, min_pf: float, - sort_by: str = "pnl", + sort_by: str = "score", sampler_name: str = "tpe", seed: Optional[int] = None, n_jobs: int = 1, @@ -441,16 +441,18 @@ def run_breakout_optuna( if result is None: trial.set_user_attr("gates_ok", False) return _FAIL_OBJECTIVE - obj = float(result["win_rate"]) if sort_by == "win_rate" else float(result["total_pnl"]) trial.set_user_attr("gates_ok", True) trial.set_user_attr("total_pnl", float(result["total_pnl"])) trial.set_user_attr("win_rate", float(result["win_rate"])) trial.set_user_attr("pf", float(result.get("pf") or 0)) + trial.set_user_attr("mdd", float(result.get("mdd") or 0)) trial.set_user_attr("total_trades", int(result["total_trades"])) trial.set_user_attr("merged_json", json.dumps(result.get("merged_params") or {}, ensure_ascii=False)) + from kis_trader.backtest.optuna_common import optuna_store_trial_score_user_attrs set_optuna_trial_stability_attrs(trial, result) - return obj - + return optuna_store_trial_score_user_attrs( + trial, result, sort_by, start=ctx.start, end=ctx.end, strategy="breakout", + ) logger.info("🔬 Optuna BREAKOUT | study=%s | trials=%d", study_name, n_trials) t0 = time.time() try: @@ -466,6 +468,7 @@ def run_breakout_optuna( ) elapsed = time.time() - t0 + from kis_trader.backtest.optuna_common import optuna_score_fields_from_trial passing: List[Dict[str, Any]] = [] for trial in study.trials: if trial.state != optuna.trial.TrialState.COMPLETE: @@ -484,15 +487,16 @@ def run_breakout_optuna( "win_rate": float(trial.user_attrs.get("win_rate") or 0), "total_pnl": float(trial.user_attrs.get("total_pnl") or 0), "pf": float(trial.user_attrs.get("pf") or 0), + "mdd": float(trial.user_attrs.get("mdd") or 0), + **optuna_score_fields_from_trial(trial), + "period_daily_avg_pnl": float(trial.user_attrs.get("period_daily_avg_pnl") or 0), "optuna_trial_number": trial.number, } row.update(stability_fields_from_trial_attrs(trial)) passing.append(row) - if sort_by == "win_rate": - passing.sort(key=lambda r: (-r["win_rate"], -r["total_pnl"])) - else: - passing.sort(key=lambda r: (-r["total_pnl"], -r["win_rate"])) + from kis_trader.backtest.optuna_common import _sort_optuna_rows + passing = _sort_optuna_rows(passing, sort_by) tiers = build_optuna_result_tiers(passing, sort_by=sort_by) hints: Dict[str, str] = {} @@ -521,6 +525,8 @@ def run_breakout_optuna( "elapsed_sec": round(elapsed, 1), **tiers, } + from kis_trader.backtest.optuna_common import annotate_optuna_period_daily_avg + annotate_optuna_period_daily_avg(out_data) ts = datetime.now().strftime("%Y%m%d_%H%M%S") out_path = os.path.join(_results_dir_for_write(), f"optuna_breakout_{ctx.mode}_{ts}.json") @@ -592,7 +598,7 @@ def run_breakout_optuna( def apply_best_breakout_trial(study: optuna.Study) -> bool: - trial = pick_gated_apply_trial(study, sort_by="pnl", fail_objective=_FAIL_OBJECTIVE) + trial = pick_gated_apply_trial(study, sort_by="score", fail_objective=_FAIL_OBJECTIVE) if trial is None: logger.warning( "⚠️ 사후게이트(results_gated) 통과 trial 없음 — DB 미적용" diff --git a/kis_trader/backtest/optuna_common.py b/kis_trader/backtest/optuna_common.py index f0d381d..8ae328b 100644 --- a/kis_trader/backtest/optuna_common.py +++ b/kis_trader/backtest/optuna_common.py @@ -25,8 +25,21 @@ OPTUNA_STRATEGIES = ("tail", "momentum", "us_momentum", "breakout", "scalp", "da OPTUNA_SEARCH_MIN_WIN_RATE_DEFAULT = 0.0 OPTUNA_SEARCH_MIN_PF_DEFAULT = 0.0 OPTUNA_SEARCH_MIN_TRADES_DEFAULT = 1 +# 웹 Optuna: min_trades = 거래일 수 × 하루 최소건 (소수 잭팟 컷) +OPTUNA_MIN_TRADES_PER_DAY_DEFAULT = 2 +# 꼬리: 거래 빈도 낮음 — 탐색 min_trades 고정 (기간×일수 대신) +OPTUNA_TAIL_MIN_TRADES_DEFAULT = 1 +# 새 score: PnL/(MDD+ADD) × √(min(n,soft_n)/soft_n) +OPTUNA_SCORE_MDD_ADD_DEFAULT = 10000.0 +# 구 score: PnL / max(MDD, FLOOR) — sort_by=score_legacy +OPTUNA_SCORE_MDD_FLOOR_DEFAULT = 10000.0 +# soft_n 기본 = 하루최소건 × 일수(기본 2) — 짧은 구간에서 15 고정이 과함 +OPTUNA_SCORE_TRADE_SOFT_DAYS_DEFAULT = 2 OPTUNA_REPORT_MIN_WIN_RATE_DEFAULT = 40.0 OPTUNA_REPORT_MIN_PF_DEFAULT = 1.0 +OPTUNA_SORT_BY_DEFAULT = "score" +OPTUNA_SORT_BY_CHOICES = ("score", "score_legacy", "pnl", "daily_avg", "win_rate") +OPTUNA_WEB_SORT_BY_CHOICES = ("score", "score_legacy", "pnl", "daily_avg") # 일별 손익 안정성 티어 (results_stable) — 학습1위/gated 와 별도 후보 # 손실일·최악일·일PnL 분산으로 “합산만 큰” 후보를 걸러낸다. @@ -45,6 +58,260 @@ def optuna_search_gate_defaults() -> Tuple[float, float, int]: ) +def optuna_min_trades_per_day(strategy: Optional[str] = None) -> int: + """기간 자동 min_trades 의 하루 기준 건수 (기본 2). 꼬리는 resolve 에서 별도.""" + return max(1, int(get_env_int( + "OPTUNA_MIN_TRADES_PER_DAY", OPTUNA_MIN_TRADES_PER_DAY_DEFAULT, + ))) + + +def optuna_tail_min_trades() -> int: + """꼬리 Optuna 탐색 min_trades (기본 1 — 1주일이어도 후보 0건 방지).""" + return max(1, int(get_env_int( + "OPTUNA_TAIL_MIN_TRADES", OPTUNA_TAIL_MIN_TRADES_DEFAULT, + ))) + + +def resolve_optuna_min_trades( + start: Any, + end: Any, + strategy: Optional[str] = None, +) -> Dict[str, Any]: + """ + 웹 Optuna용 min_trades. + + - tail: OPTUNA_TAIL_MIN_TRADES (기본 1, 기간 무관) + - 그 외: max(1, 거래일수 × OPTUNA_MIN_TRADES_PER_DAY) + CLI --min_trades 직접 지정 시 이 함수를 쓰지 않아도 됨. + """ + from kis_trader.utils.kr_trading_day import count_kr_trading_days + + strat = str(strategy or "").strip().lower() + n_days = count_kr_trading_days(start, end) + if strat == "tail": + min_tr = optuna_tail_min_trades() + return { + "min_trades": int(min_tr), + "n_trading_days": int(n_days), + "min_trades_per_day": 0, + "min_trades_source": "tail_fixed", + } + per_day = optuna_min_trades_per_day(strat) + min_tr = max(1, int(n_days) * int(per_day)) + return { + "min_trades": int(min_tr), + "n_trading_days": int(n_days), + "min_trades_per_day": int(per_day), + "min_trades_source": "period_auto", + } + + +def annotate_optuna_period_daily_avg(out_data: Optional[Dict[str, Any]]) -> None: + """결과 JSON 행에 기간 일평균 PnL(총손익÷거래일) 붙임. 활성일 mean 과 별개.""" + if not isinstance(out_data, dict): + return + from kis_trader.utils.kr_trading_day import count_kr_trading_days + + start = out_data.get("start") + end = out_data.get("end") + try: + n_days = int(out_data.get("n_trading_days") or 0) + except (TypeError, ValueError): + n_days = 0 + if n_days <= 0 and start and end: + n_days = count_kr_trading_days(start, end) + n_days = max(1, int(n_days or 1)) + out_data["n_trading_days"] = n_days + if out_data.get("min_trades_per_day") is None: + out_data["min_trades_per_day"] = optuna_min_trades_per_day() + keys = ( + "results", "results_all", "results_gated", "results_stable", + "results_mode", "mode_combo_results", + ) + for key in keys: + rows = out_data.get(key) + if not isinstance(rows, list): + continue + for r in rows: + if not isinstance(r, dict): + continue + try: + pnl = float(r.get("total_pnl") or 0) + except (TypeError, ValueError): + pnl = 0.0 + r["n_period_trading_days"] = n_days + r["period_daily_avg_pnl"] = round(pnl / float(n_days), 2) + + +def optuna_score_mdd_add() -> float: + """새 score 분모 MDD+ADD 의 ADD (기본 10000원).""" + return max(1.0, float(get_env_float( + "OPTUNA_SCORE_MDD_ADD", OPTUNA_SCORE_MDD_ADD_DEFAULT, + ))) + + +def optuna_score_mdd_floor(strategy: Optional[str] = None) -> float: + """ + 구 score 분모 하한 max(MDD, floor). + + 전략별 {PREFIX}_SCORE_MDD_FLOOR 가 있으면 우선, 없으면 OPTUNA_SCORE_MDD_FLOOR. + """ + prefix_map = { + "tail": "TAIL", + "momentum": "MOMENTUM", + "us_momentum": "US_MOMENTUM", + "breakout": "BREAKOUT", + "scalp": "SCALP", + } + strat = str(strategy or "").strip().lower() + prefix = prefix_map.get(strat) + if prefix: + raw = str(get_env_from_db(f"{prefix}_SCORE_MDD_FLOOR", "") or "").strip() + if raw: + try: + return max(1.0, float(raw)) + except (TypeError, ValueError): + pass + return max(1.0, float(get_env_float( + "OPTUNA_SCORE_MDD_FLOOR", OPTUNA_SCORE_MDD_FLOOR_DEFAULT, + ))) + + +def optuna_score_trade_soft_n() -> int: + """ + 새 score 거래수 soft 포화점. + + OPTUNA_SCORE_TRADE_SOFT_N 이 있으면 그 값. + 없으면 OPTUNA_MIN_TRADES_PER_DAY × OPTUNA_SCORE_TRADE_SOFT_DAYS(기본 2) + → 하루 2건 × 2일 = 4 (짧은 구간에서 15 고정 과감점 방지). + """ + raw = str(get_env_from_db("OPTUNA_SCORE_TRADE_SOFT_N", "") or "").strip() + if raw: + try: + return max(1, int(float(raw))) + except (TypeError, ValueError): + pass + days = max(1, int(get_env_int( + "OPTUNA_SCORE_TRADE_SOFT_DAYS", OPTUNA_SCORE_TRADE_SOFT_DAYS_DEFAULT, + ))) + return max(1, int(optuna_min_trades_per_day()) * int(days)) + + +def normalize_optuna_sort_by(sort_by: Any, *, web: bool = False) -> str: + """sort_by 정규화. 웹은 score|pnl|daily_avg 만.""" + sb = str(sort_by or "").strip().lower() + if sb in ("score_v2", "risk_score"): + sb = "score" + if sb in ("legacy", "score_v1", "score_floor", "pnl_mdd"): + sb = "score_legacy" + if sb in ("period_daily_avg", "daily", "avg_daily"): + sb = "daily_avg" + if sb in ("stability", "stable"): + return "stability" + allowed = OPTUNA_WEB_SORT_BY_CHOICES if web else OPTUNA_SORT_BY_CHOICES + if not sb or sb not in allowed: + return OPTUNA_SORT_BY_DEFAULT + return sb + + +def optuna_objective_value( + result: Optional[Dict[str, Any]], + sort_by: str = "score", + *, + start: Any = None, + end: Any = None, + n_trading_days: Optional[int] = None, + strategy: Optional[str] = None, +) -> float: + """ + Optuna 목적함수 (maximize). + + - score: (PnL / (MDD + ADD)) × √(min(trades, soft_n) / soft_n) + - score_legacy: PnL / max(MDD, FLOOR) — (구) 순익/MDD하한 + - daily_avg: PnL ÷ 기간 한국거래일 + - pnl: 총손익 + - win_rate: 승률 (CLI) + """ + r = result if isinstance(result, dict) else {} + sb = normalize_optuna_sort_by(sort_by, web=False) + if sb == "stability": + sb = OPTUNA_SORT_BY_DEFAULT + try: + pnl = float(r.get("total_pnl") or 0) + except (TypeError, ValueError): + pnl = 0.0 + if sb == "win_rate": + try: + return float(r.get("win_rate") or 0) + except (TypeError, ValueError): + return 0.0 + if sb == "pnl": + return pnl + if sb == "daily_avg": + n = n_trading_days + if n is None or int(n or 0) <= 0: + if start is not None and end is not None: + from kis_trader.utils.kr_trading_day import count_kr_trading_days + n = count_kr_trading_days(start, end) + else: + n = 1 + return pnl / float(max(1, int(n))) + if sb == "score_legacy": + try: + mdd = float(r.get("mdd") or 0) + except (TypeError, ValueError): + mdd = 0.0 + floor = optuna_score_mdd_floor(strategy) + return pnl / max(mdd, floor) + # score (수익·낙폭·표본) + try: + mdd = float(r.get("mdd") or 0) + except (TypeError, ValueError): + mdd = 0.0 + try: + trades = float(r.get("total_trades") or 0) + except (TypeError, ValueError): + trades = 0.0 + add = optuna_score_mdd_add() + soft_n = float(optuna_score_trade_soft_n()) + soft = (min(max(0.0, trades), soft_n) / soft_n) ** 0.5 + return (pnl / (max(0.0, mdd) + add)) * soft + + +def optuna_store_trial_score_user_attrs( + trial: Any, + result: Dict[str, Any], + sort_by: str, + *, + start: Any = None, + end: Any = None, + strategy: Optional[str] = None, +) -> float: + """trial score·score_legacy·일평균 저장 후 sort_by 목적값 반환.""" + kw = {"start": start, "end": end, "strategy": strategy} + trial.set_user_attr( + "score", + float(optuna_objective_value(result, "score", **kw)), + ) + trial.set_user_attr( + "score_legacy", + float(optuna_objective_value(result, "score_legacy", **kw)), + ) + trial.set_user_attr( + "period_daily_avg_pnl", + float(optuna_objective_value(result, "daily_avg", **kw)), + ) + return float(optuna_objective_value(result, sort_by, **kw)) + + +def optuna_score_fields_from_trial(trial: Any) -> Dict[str, float]: + """JSON 행용 score / score_legacy.""" + return { + "score": float(trial.user_attrs.get("score") or 0), + "score_legacy": float(trial.user_attrs.get("score_legacy") or 0), + } + + def optuna_report_gate_defaults() -> Tuple[float, float, int]: """결과 후보·apply 사후 필터 (기본 승률40·PF1.0·min_trades=탐색과 동일).""" _sw, _sp, min_tr = optuna_search_gate_defaults() @@ -60,7 +327,7 @@ def optuna_report_gate_defaults() -> Tuple[float, float, int]: def _sort_optuna_rows(rows: List[Dict[str, Any]], sort_by: str) -> List[Dict[str, Any]]: - sb = (sort_by or "pnl").strip().lower() + sb = normalize_optuna_sort_by(sort_by, web=False) out = list(rows) def _f(r: Dict[str, Any], k: str) -> float: @@ -71,9 +338,25 @@ def _sort_optuna_rows(rows: List[Dict[str, Any]], sort_by: str) -> List[Dict[str if sb == "score": out.sort(key=lambda r: (-_f(r, "score"), -_f(r, "total_pnl"), -_f(r, "win_rate"))) + elif sb == "score_legacy": + out.sort( + key=lambda r: ( + -_f(r, "score_legacy" if r.get("score_legacy") is not None else "score"), + -_f(r, "total_pnl"), + -_f(r, "win_rate"), + ), + ) + elif sb == "daily_avg": + def _avg_key(r: Dict[str, Any]) -> Tuple[float, float, float]: + if r.get("period_daily_avg_pnl") is not None: + avg = _f(r, "period_daily_avg_pnl") + else: + avg = _f(r, "daily_pnl_mean") + return (-avg, -_f(r, "total_pnl"), -_f(r, "win_rate")) + out.sort(key=_avg_key) elif sb == "win_rate": out.sort(key=lambda r: (-_f(r, "win_rate"), -_f(r, "total_pnl"))) - elif sb in ("stability", "stable"): + elif sb == "stability": # 일평균 − λ·표준편차(stability_score) 우선 · 최악일 · 합산 PnL out.sort( key=lambda r: ( @@ -917,6 +1200,11 @@ def ensure_optuna_gate_env_defaults(db: Any = None) -> None: "PARAM_SEARCH_OPTUNA_MIN_WIN_RATE": str(OPTUNA_SEARCH_MIN_WIN_RATE_DEFAULT), "PARAM_SEARCH_OPTUNA_MIN_PF": str(OPTUNA_SEARCH_MIN_PF_DEFAULT), "PARAM_SEARCH_OPTUNA_MIN_TRADES": str(OPTUNA_SEARCH_MIN_TRADES_DEFAULT), + "OPTUNA_MIN_TRADES_PER_DAY": str(OPTUNA_MIN_TRADES_PER_DAY_DEFAULT), + "OPTUNA_TAIL_MIN_TRADES": str(OPTUNA_TAIL_MIN_TRADES_DEFAULT), + "OPTUNA_SCORE_MDD_ADD": str(int(OPTUNA_SCORE_MDD_ADD_DEFAULT)), + "OPTUNA_SCORE_MDD_FLOOR": str(int(OPTUNA_SCORE_MDD_FLOOR_DEFAULT)), + "OPTUNA_SCORE_TRADE_SOFT_DAYS": str(OPTUNA_SCORE_TRADE_SOFT_DAYS_DEFAULT), "PARAM_SEARCH_OPTUNA_REPORT_MIN_WIN_RATE": str(OPTUNA_REPORT_MIN_WIN_RATE_DEFAULT), "PARAM_SEARCH_OPTUNA_REPORT_MIN_PF": str(OPTUNA_REPORT_MIN_PF_DEFAULT), "PARAM_SEARCH_OPTUNA_BRIEFING_AI": "1", diff --git a/kis_trader/backtest/optuna_momentum.py b/kis_trader/backtest/optuna_momentum.py index 16726c0..f4adfcb 100644 --- a/kis_trader/backtest/optuna_momentum.py +++ b/kis_trader/backtest/optuna_momentum.py @@ -420,15 +420,17 @@ def _make_sampler(name: str, seed: Optional[int]): return TPESampler(seed=seed, multivariate=True, warn_independent_sampling=False) -def _momentum_objective_value(result: Dict[str, Any], sort_by: str) -> float: - pnl = float(result["total_pnl"]) - if sort_by == "score": - mdd_floor = get_env_float("MOMENTUM_SCORE_MDD_FLOOR", 10000.0) - mdd = float(result.get("mdd") or 0) - return pnl / max(mdd, mdd_floor) - if sort_by == "win_rate": - return float(result["win_rate"]) - return pnl +def _momentum_objective_value( + result: Dict[str, Any], + sort_by: str, + *, + start: Any = None, + end: Any = None, +) -> float: + from kis_trader.backtest.optuna_common import optuna_objective_value + return float(optuna_objective_value( + result, sort_by, start=start, end=end, strategy="momentum", + )) def run_momentum_optuna( @@ -495,17 +497,18 @@ def run_momentum_optuna( if result is None: trial.set_user_attr("gates_ok", False) return _FAIL_OBJECTIVE - obj = _momentum_objective_value(result, sort_by) trial.set_user_attr("gates_ok", True) trial.set_user_attr("total_pnl", float(result["total_pnl"])) trial.set_user_attr("win_rate", float(result["win_rate"])) trial.set_user_attr("pf", float(result.get("pf") or 0)) trial.set_user_attr("mdd", float(result.get("mdd") or 0)) - trial.set_user_attr("score", float(obj if sort_by == "score" else _momentum_objective_value(result, "score"))) trial.set_user_attr("total_trades", int(result["total_trades"])) trial.set_user_attr("merged_json", json.dumps(result.get("merged_params") or {}, ensure_ascii=False)) + from kis_trader.backtest.optuna_common import optuna_store_trial_score_user_attrs set_optuna_trial_stability_attrs(trial, result) - return float(obj) + return optuna_store_trial_score_user_attrs( + trial, result, sort_by, start=ctx.start, end=ctx.end, strategy="momentum", + ) logger.info( "🔬 Optuna MOMENTUM | study=%s | trials=%d | sort=%s", @@ -525,6 +528,8 @@ def run_momentum_optuna( ) elapsed = time.time() - t0 + from kis_trader.backtest.optuna_common import optuna_score_fields_from_trial + from kis_trader.backtest.optuna_common import optuna_score_fields_from_trial passing: List[Dict[str, Any]] = [] for trial in study.trials: if trial.state != optuna.trial.TrialState.COMPLETE: @@ -544,7 +549,8 @@ def run_momentum_optuna( "total_pnl": float(trial.user_attrs.get("total_pnl") or 0), "pf": float(trial.user_attrs.get("pf") or 0), "mdd": float(trial.user_attrs.get("mdd") or 0), - "score": float(trial.user_attrs.get("score") or 0), + **optuna_score_fields_from_trial(trial), + "period_daily_avg_pnl": float(trial.user_attrs.get("period_daily_avg_pnl") or 0), "optuna_trial_number": trial.number, } row.update(stability_fields_from_trial_attrs(trial)) @@ -576,6 +582,8 @@ def run_momentum_optuna( "elapsed_sec": round(elapsed, 1), **tiers, } + from kis_trader.backtest.optuna_common import annotate_optuna_period_daily_avg + annotate_optuna_period_daily_avg(out_data) ts = datetime.now().strftime("%Y%m%d_%H%M%S") _mk = str(getattr(ctx, "market", "") or "").strip().upper() diff --git a/kis_trader/backtest/optuna_scalping.py b/kis_trader/backtest/optuna_scalping.py index 02145f8..4795bfa 100644 --- a/kis_trader/backtest/optuna_scalping.py +++ b/kis_trader/backtest/optuna_scalping.py @@ -366,15 +366,17 @@ def _make_sampler(name: str, seed: Optional[int]): return TPESampler(seed=seed, multivariate=True, warn_independent_sampling=False) -def _scalp_objective_value(result: Dict[str, Any], sort_by: str) -> float: - pnl = float(result["total_pnl"]) - if sort_by == "score": - mdd_floor = get_env_float("SCALP_SCORE_MDD_FLOOR", 5000.0) - mdd = float(result.get("mdd") or 0) - return pnl / max(mdd, mdd_floor) - if sort_by == "win_rate": - return float(result["win_rate"]) - return pnl +def _scalp_objective_value( + result: Dict[str, Any], + sort_by: str, + *, + start: Any = None, + end: Any = None, +) -> float: + from kis_trader.backtest.optuna_common import optuna_objective_value + return float(optuna_objective_value( + result, sort_by, start=start, end=end, strategy="scalp", + )) def run_scalp_optuna( @@ -386,7 +388,7 @@ def run_scalp_optuna( min_trades: int, min_win_rate: float, min_pf: float, - sort_by: str = "pnl", + sort_by: str = "score", sampler_name: str = "tpe", seed: Optional[int] = None, n_jobs: int = 1, @@ -438,23 +440,21 @@ def run_scalp_optuna( if result is None: trial.set_user_attr("gates_ok", False) return _FAIL_OBJECTIVE - obj = _scalp_objective_value(result, sort_by) trial.set_user_attr("gates_ok", True) trial.set_user_attr("total_pnl", float(result["total_pnl"])) trial.set_user_attr("win_rate", float(result["win_rate"])) trial.set_user_attr("pf", float(result.get("pf") or 0)) trial.set_user_attr("mdd", float(result.get("mdd") or 0)) - trial.set_user_attr( - "score", - float(obj if sort_by == "score" else _scalp_objective_value(result, "score")), - ) trial.set_user_attr("total_trades", int(result["total_trades"])) trial.set_user_attr( "merged_json", json.dumps(result.get("merged_params") or {}, ensure_ascii=False), ) + from kis_trader.backtest.optuna_common import optuna_store_trial_score_user_attrs set_optuna_trial_stability_attrs(trial, result) - return float(obj) + return optuna_store_trial_score_user_attrs( + trial, result, sort_by, start=ctx.start, end=ctx.end, strategy="scalp", + ) logger.info( "🔬 Optuna SCALP | study=%s | mode=%s | trials=%d | sort=%s | universe=%s | ticks=%s", @@ -475,6 +475,7 @@ def run_scalp_optuna( ) elapsed = time.time() - t0 + from kis_trader.backtest.optuna_common import optuna_score_fields_from_trial passing: List[Dict[str, Any]] = [] for trial in study.trials: if trial.state != optuna.trial.TrialState.COMPLETE: @@ -494,18 +495,15 @@ def run_scalp_optuna( "total_pnl": float(trial.user_attrs.get("total_pnl") or 0), "pf": float(trial.user_attrs.get("pf") or 0), "mdd": float(trial.user_attrs.get("mdd") or 0), - "score": float(trial.user_attrs.get("score") or 0), + **optuna_score_fields_from_trial(trial), + "period_daily_avg_pnl": float(trial.user_attrs.get("period_daily_avg_pnl") or 0), "optuna_trial_number": trial.number, } row.update(stability_fields_from_trial_attrs(trial)) passing.append(row) - if sort_by == "score": - passing.sort(key=lambda r: (-r["score"], -r["total_pnl"], -r["win_rate"])) - elif sort_by == "win_rate": - passing.sort(key=lambda r: (-r["win_rate"], -r["total_pnl"])) - else: - passing.sort(key=lambda r: (-r["total_pnl"], -r["win_rate"])) + from kis_trader.backtest.optuna_common import _sort_optuna_rows + passing = _sort_optuna_rows(passing, sort_by) tiers = build_optuna_result_tiers(passing, sort_by=sort_by) @@ -540,6 +538,8 @@ def run_scalp_optuna( "tick_backtest": ctx.tick_backtest_meta, **tiers, } + from kis_trader.backtest.optuna_common import annotate_optuna_period_daily_avg + annotate_optuna_period_daily_avg(out_data) ts = datetime.now().strftime("%Y%m%d_%H%M%S") out_path = os.path.join(_results_dir_for_write(), f"optuna_scalp_{ctx.mode}_{ts}.json") diff --git a/kis_trader/backtest/optuna_study_store.py b/kis_trader/backtest/optuna_study_store.py index 80fd5f3..fa7a684 100644 --- a/kis_trader/backtest/optuna_study_store.py +++ b/kis_trader/backtest/optuna_study_store.py @@ -695,6 +695,11 @@ def finalize_optuna_export( out_data["optuna_study_trials"] = target out_data["optuna_n_complete"] = n_c out_data["optuna_n_finished"] = n_f + try: + from kis_trader.backtest.optuna_common import annotate_optuna_period_daily_avg + annotate_optuna_period_daily_avg(out_data) + except Exception: + pass 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) diff --git a/kis_trader/backtest/optuna_web_jobs.py b/kis_trader/backtest/optuna_web_jobs.py index 2642b73..7aa4876 100644 --- a/kis_trader/backtest/optuna_web_jobs.py +++ b/kis_trader/backtest/optuna_web_jobs.py @@ -735,7 +735,12 @@ def _join_argv_for_study( 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" + from kis_trader.backtest.optuna_common import ( + normalize_optuna_sort_by, + resolve_optuna_min_trades, + ) + sort_by = normalize_optuna_sort_by(meta.get("sort_by") or "score", web=True) + _mt = resolve_optuna_min_trades(start, end, strat) argv = [ py_bin, "-u", "kis_trader/backtest/param_search_optuna.py", "--strategy", strat, @@ -743,7 +748,7 @@ def _join_argv_for_study( "--start", start, "--end", end, "--trials", trials, - "--min_trades", "1", + "--min_trades", str(int(_mt["min_trades"])), "--min_win_rate", "0", "--min_pf", "0", "--orderbook-filter", "off", @@ -1148,11 +1153,11 @@ def _row_metrics( "pf": row.get("pf"), "score": row.get("score"), } - # 일별 안정성 (신규 Optuna JSON) + # 일별 안정성 (신규 Optuna JSON) + 기간 일평균 for k in ( "stability_score", "n_losing_days", "n_active_days", "worst_day_pnl", "best_day_pnl", "daily_pnl_mean", "daily_pnl_std", - "daily_pnl", + "daily_pnl", "period_daily_avg_pnl", "n_period_trading_days", ): if row.get(k) is not None: out[k] = row.get(k) @@ -1190,11 +1195,15 @@ def _summarize_result_data( ) -> Optional[Dict[str, Any]]: if not isinstance(data, dict): return None + from kis_trader.backtest.optuna_common import ( + annotate_optuna_period_daily_avg, + resolve_results_stable, + ) from kis_trader.backtest.optuna_postprocess_topn import resolve_post_top_n + annotate_optuna_period_daily_avg(data) top_n = resolve_post_top_n(10) gated = list(data.get("results_gated") 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 @@ -1348,6 +1357,9 @@ def _summarize_result_data( "mode": data.get("mode"), "start": data.get("start"), "end": data.get("end"), + "n_trading_days": data.get("n_trading_days"), + "min_trades": data.get("min_trades"), + "min_trades_per_day": data.get("min_trades_per_day"), "n_gated": len(gated), "n_stable": len(stable), "n_all": len(allr), @@ -2264,6 +2276,7 @@ def start_optuna_job( end: str, trials: int = 200, mode: str = "tpe", + sort_by: Optional[str] = None, symbol: Optional[str] = None, universe_history_source: Optional[str] = None, candle_source: Optional[str] = None, @@ -2331,12 +2344,23 @@ def start_optuna_job( if start > end: start, end = end, start + from kis_trader.backtest.optuna_common import ( + normalize_optuna_sort_by, + resolve_optuna_min_trades, + ) + _mt_info = resolve_optuna_min_trades(start, end, picked[0] if len(picked) == 1 else None) + min_trades = int(_mt_info["min_trades"]) + sort_by = normalize_optuna_sort_by(sort_by or "score", web=True) + ts = datetime.now().strftime("%Y%m%d_%H%M%S") started_ts = time.time() env = os.environ.copy() env["PYTHONUNBUFFERED"] = "1" env["BACKTEST_UNIVERSE_HISTORY_SOURCE"] = hist_src + # 사후 report 게이트 거래수 = 탐색과 동일(기간 자동) + env["PARAM_SEARCH_OPTUNA_REPORT_MIN_TRADES"] = str(min_trades) + env["SORT_BY"] = sort_by _labels = { "momentum": "모멘텀", @@ -2381,7 +2405,7 @@ def start_optuna_job( env["MODE"] = mode env["MIN_WIN_RATE"] = "0" env["MIN_PF"] = "0" - env["MIN_TRADES"] = "1" + env["MIN_TRADES"] = str(min_trades) env["STRATEGIES"] = " ".join(picked) env["UNIVERSE_HISTORY_SOURCE"] = hist_src env["TAIL_OPTUNA_ENTRY_MODES"] = " ".join(tail_ems if "tail" in picked else ["align"]) @@ -2430,7 +2454,6 @@ def start_optuna_job( study_name = f"{strat}_{mode}_{start.replace('-', '')}_{end.replace('-', '')}_{ts}" log_path = ROOT / "logs" / f"optuna_web_{strat}_{ts}.log" label = _labels.get(strat, strat) - sort_by = "score" if strat in ("momentum", "us_momentum", "scalp") else "pnl" cmd = [ str(PY if PY.is_file() else "python3"), "-u", @@ -2440,7 +2463,7 @@ def start_optuna_job( "--start", start, "--end", end, "--trials", str(trials), - "--min_trades", "1", + "--min_trades", str(min_trades), "--min_win_rate", "0", "--min_pf", "0", "--orderbook-filter", (bo_oms[0] if strat == "breakout" else "off"), @@ -2498,9 +2521,13 @@ def start_optuna_job( "strategy": strat_field, "strategies": picked, "mode": mode, + "sort_by": sort_by, "start": start, "end": end, "trials": trials, + "min_trades": min_trades, + "n_trading_days": int(_mt_info.get("n_trading_days") or 0), + "min_trades_per_day": int(_mt_info.get("min_trades_per_day") or 0), "study_trials": st_goal, "study_name": study_name, "seq_active_file": str(seq_active) if use_seq else None, diff --git a/kis_trader/backtest/param_search_optuna.py b/kis_trader/backtest/param_search_optuna.py index a39a7cc..7847357 100644 --- a/kis_trader/backtest/param_search_optuna.py +++ b/kis_trader/backtest/param_search_optuna.py @@ -474,7 +474,7 @@ def run_tail_optuna( min_trades: int, min_win_rate: float, min_pf: float, - sort_by: str = "pnl", + sort_by: str = "score", sampler_name: str = "tpe", seed: Optional[int] = None, n_jobs: int = 1, @@ -529,17 +529,21 @@ def run_tail_optuna( trial.set_user_attr("gates_ok", False) return _FAIL_OBJECTIVE + from kis_trader.backtest.optuna_common import ( + optuna_score_fields_from_trial, + optuna_store_trial_score_user_attrs, + ) trial.set_user_attr("gates_ok", True) trial.set_user_attr("total_pnl", float(result["total_pnl"])) trial.set_user_attr("win_rate", float(result["win_rate"])) trial.set_user_attr("pf", float(result.get("pf") or 0)) + trial.set_user_attr("mdd", float(result.get("mdd") or 0)) trial.set_user_attr("total_trades", int(result["total_trades"])) trial.set_user_attr("params_json", json.dumps(result["params"], ensure_ascii=False)) set_optuna_trial_stability_attrs(trial, result) - - if sort_by == "win_rate": - return float(result["win_rate"]) - return float(result["total_pnl"]) + return optuna_store_trial_score_user_attrs( + trial, result, sort_by, start=ctx.start, end=ctx.end, strategy="tail", + ) logger.info( "🔬 Optuna 시작 | study=%s | trials=%d | sampler=%s | storage=%s | n_jobs=%d", @@ -561,6 +565,7 @@ def run_tail_optuna( logger.info("✅ Optuna 완료 | %.1f초 | 완료 trial %d", elapsed, len(study.trials)) # JSON export — study.user_attrs 기준 (n_jobs>1 에도 안전) + from kis_trader.backtest.optuna_common import optuna_score_fields_from_trial passing: List[Dict[str, Any]] = [] for trial in study.trials: if trial.state != optuna.trial.TrialState.COMPLETE: @@ -579,14 +584,15 @@ def run_tail_optuna( "win_rate": float(trial.user_attrs.get("win_rate") or 0), "total_pnl": float(trial.user_attrs.get("total_pnl") or 0), "pf": float(trial.user_attrs.get("pf") or 0), + "mdd": float(trial.user_attrs.get("mdd") or 0), + **optuna_score_fields_from_trial(trial), + "period_daily_avg_pnl": float(trial.user_attrs.get("period_daily_avg_pnl") or 0), "optuna_trial_number": trial.number, } row.update(stability_fields_from_trial_attrs(trial)) passing.append(row) - if sort_by == "pnl": - passing.sort(key=lambda r: (-float(r["total_pnl"]), -float(r["win_rate"]))) - else: - passing.sort(key=lambda r: (-float(r["win_rate"]), -float(r["total_pnl"]))) + from kis_trader.backtest.optuna_common import _sort_optuna_rows + passing = _sort_optuna_rows(passing, sort_by) tiers = build_optuna_result_tiers(passing, sort_by=sort_by) @@ -621,6 +627,8 @@ def run_tail_optuna( "elapsed_sec": round(elapsed, 1), **tiers, } + from kis_trader.backtest.optuna_common import annotate_optuna_period_daily_avg + annotate_optuna_period_daily_avg(out_data) ts = datetime.now().strftime("%Y%m%d_%H%M%S") out_name = f"optuna_tail_{ctx.mode}_{ts}.json" @@ -723,7 +731,7 @@ def run_tail_optuna( def apply_best_trial(study: optuna.Study, ctx: TailSearchContext) -> bool: """사후게이트 통과 trial → env_config (총손익≤0 스킵).""" - trial = pick_gated_apply_trial(study, sort_by="pnl", fail_objective=_FAIL_OBJECTIVE) + trial = pick_gated_apply_trial(study, sort_by="score", fail_objective=_FAIL_OBJECTIVE) if trial is None: logger.warning( "⚠️ 사후게이트(results_gated) 통과 trial 없음 — DB 미적용" @@ -808,7 +816,7 @@ def main() -> None: parser.add_argument( "--sort-by", default=None, dest="sort_by", - help="목적함수: tail/breakout pnl|win_rate, momentum score|pnl|win_rate (미지정=전략 기본)", + help="목적함수: score|score_legacy|pnl|daily_avg|win_rate (미지정=score 전 전략 공통)", ) add_search_filter_cli_args(parser) # Optuna: 탐색 중 승률·PF 게이트 OFF(0) — TPE가 PnL 차이를 학습. 사후 results_gated 로 후보 분리. @@ -918,6 +926,9 @@ def main() -> None: logger.error("❌ --strategy 는 tail/momentum/us_momentum/breakout/scalp 중 하나") sys.exit(2) + # CLI --min_trades 와 사후 results_gated 거래수 게이트 정렬 (seq: tail=1 / 타전략=18 등) + os.environ["PARAM_SEARCH_OPTUNA_REPORT_MIN_TRADES"] = str(max(1, int(args.min_trades))) + allowed_modes = STRATEGY_MODES.get(strategy, []) mode = (args.mode or "fast").strip().lower() if mode not in allowed_modes: @@ -926,14 +937,15 @@ def main() -> None: sort_by = (args.sort_by or "").strip().lower() if not sort_by: - sort_by = "score" if strategy in ("momentum", "us_momentum", "scalp") else "pnl" - momentum_sort = {"score", "pnl", "win_rate"} - basic_sort = {"pnl", "win_rate"} - if strategy in ("momentum", "us_momentum", "scalp") and sort_by not in momentum_sort: - logger.error("❌ %s --sort-by 는 score|pnl|win_rate", strategy) - sys.exit(2) - if strategy in ("tail", "breakout") and sort_by not in basic_sort: - logger.error("❌ %s --sort-by 는 pnl|win_rate", strategy) + from kis_trader.backtest.optuna_common import OPTUNA_SORT_BY_DEFAULT + sort_by = OPTUNA_SORT_BY_DEFAULT + from kis_trader.backtest.optuna_common import ( + OPTUNA_SORT_BY_CHOICES, + normalize_optuna_sort_by, + ) + sort_by = normalize_optuna_sort_by(sort_by, web=False) + if sort_by not in OPTUNA_SORT_BY_CHOICES: + logger.error("❌ --sort-by 는 score|score_legacy|pnl|daily_avg|win_rate") sys.exit(2) lock_name = optuna_run_lock_name(strategy) diff --git a/scripts/run_optuna_4strat_tpe_seq.sh b/scripts/run_optuna_4strat_tpe_seq.sh index 3f054c6..63e8bb1 100755 --- a/scripts/run_optuna_4strat_tpe_seq.sh +++ b/scripts/run_optuna_4strat_tpe_seq.sh @@ -76,14 +76,18 @@ run_one() { study="${strat}_${bo_extra}_tpe_${START//-/}_${END//-/}_${ts}" log="logs/optuna_${strat}_${bo_extra}_tpe_${ts}.log" fi - sort_by="pnl" - case "$strat" in - momentum|us_momentum|scalp) sort_by="score" ;; + sort_by="${SORT_BY:-score}" + case "$sort_by" in + score|score_legacy|pnl|daily_avg|win_rate) ;; + *) sort_by="score" ;; esac + local min_trades_arg + min_trades_arg=$("$PY" -c "from kis_trader.backtest.optuna_common import resolve_optuna_min_trades; print(resolve_optuna_min_trades('${START}', '${END}', '${strat}')['min_trades'])") + { echo "" - echo "-------- [$strat${entry_mode:+/$entry_mode}${sl_mode:+/$sl_mode}${bo_extra:+/$bo_extra}] START $(date -Is) study=$study univ=$UNIVERSE_HISTORY_SOURCE --------" + echo "-------- [$strat${entry_mode:+/$entry_mode}${sl_mode:+/$sl_mode}${bo_extra:+/$bo_extra}] START $(date -Is) study=$study univ=$UNIVERSE_HISTORY_SOURCE min_trades=$min_trades_arg --------" } | tee -a "$MASTER" echo "$log" > "logs/optuna_${strat}_tpe_latest.logpath" echo "$study" > "logs/optuna_${strat}_tpe_latest.study" @@ -112,7 +116,7 @@ run_one() { --start "$START" --end "$END" --trials "$TRIALS" - --min_trades "$MIN_TRADES" + --min_trades "$min_trades_arg" --min_win_rate "$MIN_WIN_RATE" --min_pf "$MIN_PF" --orderbook-filter "$([[ "$strat" == "breakout" ]] && echo "$ob_mode" || echo off)"