diff --git a/.cursor/rules/bt-form-postfilter-key-parity.mdc b/.cursor/rules/bt-form-postfilter-key-parity.mdc new file mode 100644 index 0000000..25725a8 --- /dev/null +++ b/.cursor/rules/bt-form-postfilter-key-parity.mdc @@ -0,0 +1,58 @@ +--- +description: 웹백테 폼 → params → 후처리 필터 조회 키 정합 (whipsaw·호가 등) +alwaysApply: true +--- + +# 웹백테 폼 ↔ 후처리 필터 키 정합 (whipsaw / orderbook / program) + +이 대화에서 실제로 난 사고: **웹 백테 폼 “휩쏘 ON” 토글이 후처리에서 항상 OFF로 판정**되어 옵투나↔웹백테 결과가 1원 단위로 안 맞음. 원인은 “저장 키 vs 조회 키” 이름 파편화 (`whipsaw_enabled` 저장 → `whipsaw_filter_enabled` 조회). + +## 1. 절대 원칙 — 저장 키 = 조회 키 + +- **웹 백테 요청 처리에서 `params[...] = ...` 로 세팅한 키는 후처리/엔진이 실제로 조회하는 키와 100% 동일해야 한다.** 서로 다른 이름이면 “세팅한 값”이 조용히 사라져 옵투나(=파람서치 저장 키)와 다른 엔진이 된다. +- 실제 소비자를 **grep으로 반드시 확인**한 뒤 세팅 키를 정한다. “예전 이름 습관”으로 세팅하지 말 것. +- 소비자 여러 개가 서로 다른 alias를 볼 수 있으면 **하위호환 폴백은 소비자 쪽에만**. 새 코드/새 진입점에서는 **표준 키 하나만** 세팅. + +## 2. 표준 키 (2026-09-06 통일 · 옛 alias 완전 폐기) + +| 도메인 | 표준 키 | 폐기된 이름 (재도입 금지) | +|---|---|---| +| 휩쏘 활성 | `whipsaw_filter_enabled` | `whipsaw_enabled` ← 2026-09-06 완전 제거 | +| 호가 활성 | `_orderbook_filter_enabled` | `orderbook_filter_enabled` | +| 프로그램매매 활성 | `_program_filter_enabled` | — | +| 휩쏘 서브바 초 | `whipsaw_subbar_sec` | — | +| 휩쏘 룩백 초 | `whipsaw_lookback_sec` | — | + +- 신규 키 추가 시 이 표에 함께 등록. 폐기된 이름으로 새로 만들지 말 것. +- **DB env는 별도 계층**: `{SCALP|MOMENTUM|BREAKOUT|TAIL}_WHIPSAW_FILTER_ENABLED` + 글로벌 `WHIPSAW_FILTER_ENABLED`. apply 시점(`param_search_*.apply_params_to_db`·`tail_env_keys.py`·`optuna_whipsaw_recommend`)에서 소문자 파라미터에 prefix를 붙여 변환. 파이썬/JS 파라미터 계층은 전략 무관 단일 키 유지. + +## 3. 필수 grep (수정·리뷰 시) + +웹백테 폼 / Optuna / apply / 후처리 / 프론트 5개 축을 **한 세트로** 검사한다. + +```bash +rg -n 'whipsaw_filter_enabled|whipsaw_enabled' \ + backtest_web.py static/js/backtest.js templates/backtest.html \ + kis_trader/backtest kis_trader/engine +``` + +- `backtest_web.py`의 `params["..."] = ...` (웹 폼 → params) — 저장 키 +- `kis_trader/backtest/bt_post_filters.py` — 후처리 조회 키 +- `kis_trader/backtest/param_search_*.py` — apply 맵 키 +- `kis_trader/backtest/optuna_*.py` — Optuna trial params 저장 키 +- `static/js/backtest.js` — 폼 파라미터 name (`out.whipsaw_filter=1|0`) 및 hidden 표시 + +**세팅 키 ↔ 조회 키가 다르면 완료 아님.** + +## 4. 완료 전 스모크 (필수 1회) + +1. 동일 기간·동일 파라미터로 웹 백테 실행 (휩쏘 ON) → 거래수·PnL 기록 +2. 같은 조건 Optuna best JSON 로드 → 웹 백테 재실행 +3. 두 결과가 **1원 단위 일치** 해야 정합. 안 맞으면 이 규칙 §3 grep 다시. +4. 실매 코어를 만졌으면 `python3 -u scripts/test_live_execution_validation.py` (`live-execution-validation.mdc`). + +## 5. 자주 나는 함정 + +- 프론트가 폼 파라미터를 `whipsaw_filter=1|0` 으로 보내는데, 백엔드가 이를 `params["whipsaw_enabled"]` 로 저장 → 후처리 조회 키(`whipsaw_filter_enabled`)와 불일치. **읽는 이름과 쓰는 이름을 분리해서 검사할 것.** (2026-09-06 통일로 이 경로는 해소됨) +- 하위호환 폴백을 **양쪽에 다 넣으면** 어느 쪽이 실제 진리인지 흐려져 다음 사고 촉발. 신 alias 도입 시 폴백은 **소비자 1곳만·기한부**로 두고 다음 릴리즈에서 제거. +- apply 맵(`param_search_*.py`)에 두 alias를 다 넣으면 apply는 무해히 동작해도, “통일” 계획을 반쯤 어긴 상태로 남는다. 계획 문서와 실 코드가 어긋나면 다음 리팩터에서 다시 사고. diff --git a/.cursor/rules/optuna-job-engine-label.mdc b/.cursor/rules/optuna-job-engine-label.mdc new file mode 100644 index 0000000..af17c41 --- /dev/null +++ b/.cursor/rules/optuna-job-engine-label.mdc @@ -0,0 +1,40 @@ +--- +description: Optuna job 엔진 뱃지(Rust⚡/Python🐢) — use_rust 명시 저장, 폴백 하드코딩 금지 +alwaysApply: true +--- + +# Optuna Job 엔진 뱃지 라벨링 + +이 대화에서 실제로 난 사고: 파이썬으로 돌린 스캘핑 job이 UI에서 `Rust⚡`로, 러스트 스캘핑 refine2 가 헷갈리게 표시. 원인은 `optuna_web_jobs.py`에서 `use_rust` 필드가 없는 옛 JSON에 **전략명 하드코딩 폴백**(“tail이면 Rust, 나머지는 Python”)을 붙였기 때문. + +## 1. 원칙 + +- Optuna job(`logs/optuna_web_jobs/.json`) 저장 시 **`use_rust: true|false` 를 항상 명시 저장**한다. 프론트 뱃지·필터·정렬이 이 필드를 진리로 사용한다. +- 백엔드 job_list 렌더링은 `use_rust` 가 **없거나 None이면** `engine_label = "미상"` (예: `❔`) 로만 표기. **전략명에 따라 임의로 Rust/Python을 부여하지 말 것.** +- 프론트도 `engine_label` 문자열을 신뢰. 하드코딩 매핑 금지. + +## 2. 금지 + +- `if "tail" in strategy: label = "Rust⚡" else "Python🐢"` 류 폴백. +- 신규 Rust 포팅 전략(스캘핑·모멘텀·돌파 등)이 늘어날 때 이 조건을 늘려 유지보수하는 방식. +- 옛 JSON을 뒤늦게 스캔하며 라벨을 “추정”해 덮어쓰기. + +## 3. 필수 저장 필드 + +`optuna_web_jobs` 저장 시 아래를 반드시 포함: + +- `use_rust: bool` — 실제 실행 엔진 +- `strategy: str` — 전략 코드 +- `mode: str` — Optuna mode (tpe/fast/coarse/…) +- `label: str` — UI 표시용(예: `1·2차TPE·스캘핑`). 초기화 실패 시 `_labels.get(strat, strat)` 폴백만 허용. + +## 4. 완료 전 스모크 + +1. Rust로 스캘핑 mode 1회 실행 → UI에서 `Rust⚡` 로 뜨는지 +2. Python으로 스캘핑 mode 1회 실행 → `Python🐢` 로 뜨는지 +3. `use_rust` 누락 옛 JSON 하나 열어 → `❔` (미상) 으로 뜨는지 +4. 브라우저 검증 URL은 `http://192.168.0.149:5050/` (`backtest-web-restart.mdc`) + +## 5. 참고 + +- `label`이 `None` 이면 UI가 `study_short`(`refine2` 등)로 폴백하며 사용자에게는 의미 없는 표기가 됨. 라벨은 항상 `_base_label = label if label else _labels.get(strat, strat)` 로 방어. diff --git a/.cursor/rules/verify-before-conclude-cross-strategy.mdc b/.cursor/rules/verify-before-conclude-cross-strategy.mdc index b7fce8f..9b3f607 100644 --- a/.cursor/rules/verify-before-conclude-cross-strategy.mdc +++ b/.cursor/rules/verify-before-conclude-cross-strategy.mdc @@ -1,11 +1,11 @@ --- -description: 가설은 검증 후에만 결론 · 한 전략 사고/수정 시 타전략 교차검증 필수 +description: 버그=딥다이브 후 근본원인·3안 제시 · 추측 금지 · 타전략 교차검증 alwaysApply: true --- -# 가설 검증 필수 · 전략 교차검증 (추측으로 답 금지) +# 가설 검증 · 버그 딥다이브 · 전략 교차검증 -이 대화에서 실제로 낭비된 패턴: **가설만 말하고 값 패치로 끝내기**, **한 전략만 보고 타전략 동일 이슈 미검사**. +이 대화에서 실제로 낭비된 패턴: **가설만 말하고 값 패치로 끝내기**, **추측성 대안 나열**, **한 전략만 보고 타전략 동일 이슈 미검사**. ## 1. 추측·가설로 답변 금지 @@ -18,7 +18,32 @@ alwaysApply: true - 검증에 쓴 증거(테이블·파일·시각·수치)를 보고에 **짧게** 남긴다. 없으면 “미확인”이라고 쓰고 단정 금지. - CRITICAL §5(근본원인 먼저)와 동일 정신. 값만 고치고 “고쳤다”고 하지 말 것. -## 2. 한 전략 수정·사고 조사 시 → 타전략 교차검증 (필수) +## 2. 버그 발견 시 — 함수 추적 딥다이브 → 근본 3안 (필수) + +버그·이상 수치·정합 깨짐을 발견하면 **즉시 패치하지 말고** 아래를 끝낸 뒤 보고한다. +**오래 걸려도 된다.** 짧은 추측 턴을 여러 번 돌리는 토큰 낭비를 금지하고, **한 번에 구석구석 분석**한다. + +### 2-1. 분석 (코드 근거 없이 대안 금지) + +1. 증상 진입점(로그 시그니처·함수명·콜사이트)을 확정한다. +2. 해당 함수를 **호출 체인 따라** 읽는다 (호출자 → 피호출 → 공유 헬퍼/캐시/DB/WS). +3. 분기·기본값·env·None/빈값·재시도·락·전략별 분기까지 **구석구석** 대조한다. +4. 실측 증거(코드 라인·로그·DB/스키마)로 **근본원인 1개**를 확정한다. 미확정이면 “미확인” + 다음에 볼 경로만 적고 **대안·수정 금지**. + +### 2-2. 보고 — 근본적인 대안 정확히 3개 + +근본원인 확정 **후에만**, 땜빵이 아닌 **근본 해결안 3개**를 제시한다. + +| 안 | 내용 (필수) | +|----|-------------| +| 대안 A/B/C | 무엇을 어디서 고치는지 (파일·함수) | +| 각 안 | 왜 근본원인에 닿는지 · 실매/백테/타전략 부작용 · 리스크 | +| 추천 | 3안 중 1개를 고르고 이유 1~2문장 | + +- **금지**: 코드 미추적 추측 대안, “일단 값만 바꾸기/재시도 늘리기/±1 보정” 류 증상 가리기, 근거 없는 4안 이상 나열. +- **필수**: 선보고 → 사용자 승인 후 수정 (핵심 매매/인프라는 `.cursorrules` 선수정 원칙과 동일). + +## 3. 한 전략 수정·사고 조사 시 → 타전략 교차검증 (필수) 전략 A(꼬리/모멘텀/돌파/스캘핑 등)에서 버그·잘못된 apply·단위·UI를 찾거나 고쳤으면, **보고 전에** 같은 종류의 위험이 B·C에도 있는지 검사한다. @@ -33,7 +58,7 @@ alwaysApply: true - 타전략이 정상이면 **검사한 증거**(예: 마지막 apply=gated#1, live 값=trial params)를 한 줄씩 적는다. - 타전략도 동일/유사면 **잔여 위험**으로 명시하고, 사용자 승인 없이 일괄 값 패치하지 말 것. -## 3. Optuna 적용 추적 (감사) +## 4. Optuna 적용 추적 (감사) - HTTP access 로그만으로는 body(`source=mode`)가 안 남을 수 있음. - 1차 추적: `logs/optuna_web_jobs/.json` → `applied_at` / `applied_source` / `applied_rank` / `applied_trial` / `applied_upto` diff --git a/.cursorrules b/.cursorrules index 5a5461e..99e88df 100644 --- a/.cursorrules +++ b/.cursorrules @@ -85,6 +85,7 @@ - optuna 각 전략의 hts_skip은 false 유지하고 수정하지 말 것. - 19. **땜빵용 코딩은 지양한다** - 근본원인을 고쳐야돼 항상 근본원인을 먼저찾고 초등학생도 이해하기 쉽게 설명 후 설계를 하고 보고한다. + - 버그 발견 시: 해당 함수 **호출 체인 딥다이브**(구석구석) → 원인 확정 후 **근본 대안 3개** 선보고·승인. 추측성 대안 금지. 오래 걸려도 짧은 추측 턴 반복(토큰 낭비) 금지. 상세: `.cursor/rules/verify-before-conclude-cross-strategy.mdc` §2. - 20. 수정 사항이 실매에 영향이 가는지 백테 파라미터에만만 영향이 가는지 명확히 분류 후 보고하고 수정한다. - 21. 백테 웹페이지 탭 ui 수정시 다른 전략 탭도 모두 수정되어야 한다. - 가상거래내역, 실거래내역 ui 는 모두 동일해야한다. @@ -92,10 +93,12 @@ - 23. **키움/KIS 인프라** — CRITICAL §3 (유량≠빈응답, approval 공유, REST/키발급 한도 준수, 시세실키≠매매모의, REST 웜업 1차→실패시만 증량). - 24. **에이전트 테스트·adhoc 뻘짓 금지** — CRITICAL §4 (스키마 먼저, import/캐시 실명, env 키=config 테이블 등록, 실패 1회만). - 25. **의심·불확실 시 근본원인 먼저** — CRITICAL §5 (MCP·`docs/`·공식 API 문서·기존 SafeRequest/세마포어부터. 추측 패치·한도 무시 재시도 금지). -- 25-1. **가설 검증 후 답변 · 전략 교차검증** — `.cursor/rules/verify-before-conclude-cross-strategy.mdc`. 추측만으로 결론 금지. 한 전략 사고/수정 시 타전략 동일 이슈 교차검증 필수. +- 25-1. **가설 검증 · 버그 딥다이브 · 전략 교차검증** — `.cursor/rules/verify-before-conclude-cross-strategy.mdc`. 추측 결론 금지. 버그=함수 추적 후 근본 3안 선보고. 한 전략 사고/수정 시 타전략 교차검증 필수. - 26. **구조·수정 전 지도** — `docs/like_mcp.md/` (`MODIFICATION_GUIDE` · `code_architecture` · `db_erd`) + `.cursor/rules/like-mcp-docs.mdc`. 체크리스트 본문은 룰에 복붙하지 말고 해당 MD만 연다. - 26. **국내→해외/탭 이식 — 복붙 ≠ 완료** — CRITICAL §6 (summary 키·전역/종목 저장·폼 덮어쓰기·브라우저 검증). 상세: `.cursor/rules/domestic-port-ui-parity.mdc` - 27. **해외 전략 UI = 국장과 최대한 동일** — CRITICAL §6-1. 꼭 다르게 해야 하면 **구현 전 선보고·승인**. 임의로 생략·단축 금지. +- 28. **웹백테 폼 ↔ 후처리 필터 키 정합** — `.cursor/rules/bt-form-postfilter-key-parity.mdc`. `params[...]=` 로 세팅한 키와 후처리(`bt_post_filters.py`)·엔진이 실제 조회하는 키가 100% 동일해야 함. 휩쏘 표준 키 = `whipsaw_filter_enabled`. `whipsaw_enabled`는 소비자 하위호환 폴백 전용, 새 저장 금지. +- 29. **Optuna job 엔진 뱃지** — `.cursor/rules/optuna-job-engine-label.mdc`. `use_rust: bool` 반드시 저장. 없으면 `❔`(미상). 전략명 하드코딩으로 Rust/Python 폴백 부여 금지. # [CRITICAL SYSTEM DIRECTIVES: 절대 엄수 사항 - 위반 시 작업 중지] ## 0. 🚨 봉 정합·진입 정렬 — 절대 금지 (위반 시 작업 중지) @@ -176,18 +179,22 @@ - 실패 로그를 사용자에게 길게 반복 붙여 넣지 말 것. 고치고 결과만 보고. ## 5. 🚨 의심·불확실 → 근본원인 먼저 (땜빵·추측 패치 금지) -항목 19와 동일 정신. **코드를 고치기 전에** 원인을 문서로 확정한다. +항목 19와 동일 정신. **코드를 고치기 전에** 원인을 문서로 확정한다. +버그·이상 시 **함수 호출 체인 딥다이브 → 근본원인 확정 → 근본 대안 3개 선보고** (`.cursor/rules/verify-before-conclude-cross-strategy.mdc` §2). +**오래 걸려도 된다.** 짧은 추측 턴을 여러 번 돌리지 말고 한 번에 구석구석 분석한다. 코드 미추적 **추측성 대안 제시 금지**. ### 5-1. 조사 순서 (필수) 1. **증상 로그·에러코드** (유량=5, 429, invalid approval, Unknown column 등)를 있는 그대로 분류. -2. **`docs/`** (`정합성.md`, `계정.md`, 전략 QA 등) + **구조 지도** `docs/like_mcp.md/` (수정 전 grep·허브·ERD) + 레포 헬퍼(`SafeRequest`, `KISApprovalManager`, 세마포어) 검색. -3. **MCP** (브라우저·kis-code-assistant·관련 서버) / 공식 OpenAPI·키움 문서로 한도·엔드포인트·실전·모의 도메인 확인. 거시=MD, 미시=MCP/Grep (`.cursor/rules/like-mcp-docs.mdc`). -4. 근본원인·설계를 **짧게 보고** → 승인 후 수정 (핵심 매매/인프라는 선보고). -5. API 관련 코드를 작성/수정할 때는, 본인의 지식에 의존하지 말고 반드시 연동된 MCP 도구를 우선 호출하여 스펙을 확인하라. +2. **해당 함수 호출 체인**을 Grep/Read로 구석구석 추적 (호출자·공유 헬퍼·캐시·DB/WS·전략 분기). +3. **`docs/`** (`정합성.md`, `계정.md`, 전략 QA 등) + **구조 지도** `docs/like_mcp.md/` (수정 전 grep·허브·ERD) + 레포 헬퍼(`SafeRequest`, `KISApprovalManager`, 세마포어) 검색. +4. **MCP** (브라우저·kis-code-assistant·관련 서버) / 공식 OpenAPI·키움 문서로 한도·엔드포인트·실전·모의 도메인 확인. 거시=MD, 미시=MCP/Grep (`.cursor/rules/like-mcp-docs.mdc`). +5. **근본원인 확정** 후 **근본 해결 대안 3개**(각 안: 수정 위치·부작용·리스크) + 추천 1안을 **짧게 보고** → 승인 후 수정 (핵심 매매/인프라는 선보고). +6. API 관련 코드를 작성/수정할 때는, 본인의 지식에 의존하지 말고 반드시 연동된 MCP 도구를 우선 호출하여 스펙을 확인하라. ### 5-2. 금지 - “일단 재시도·재발급·봉수 늘리기·±1 보정”으로 증상만 가리기. - 한도·키 정책을 **문서 확인 없이** 추측 구현. - 원인 미확정 상태에서 “고쳤다/안전하다” 단정. +- 코드 미분석 **추측성 대안** 나열 · 짧은 추측 턴 반복으로 토큰 낭비. ## 6. 🚨 국내→해외/탭 이식 — 복붙 ≠ 완료 (위반 시 허위 보고) 국내 탭/API를 “그대로 가져왔다”는 **복사만으로는 완료가 아니다.** diff --git a/backtest_web.py b/backtest_web.py index 3eb0939..fc5df1d 100644 --- a/backtest_web.py +++ b/backtest_web.py @@ -3143,12 +3143,12 @@ def _apply_backtest_ob_whip_overrides_from_request( _ask_req = _args_get(req, "ob_ask_max_mult") or _args_get(req, "ask_max_mult") if _ask_req not in (None, ""): params["_ob_ask_max_mult"] = float(_ask_req) + # 웹 폼 → 표준 키 whipsaw_filter_enabled (룰 28 · bt-form-postfilter-key-parity.mdc). + # 프론트는 whipsaw_filter=1|0 로 전송. whipsaw_enabled 옛 alias는 사용 금지 (2026-09-06 통일). _whip_raw = _args_get(req, "whipsaw_filter") - if _whip_raw in (None, ""): - _whip_raw = _args_get(req, "whipsaw_enabled") _whip_tg = _backtest_filter_toggle(_whip_raw) if _whip_tg is not None: - params["whipsaw_enabled"] = _whip_tg + params["whipsaw_filter_enabled"] = _whip_tg _wsub = _args_get(req, "whipsaw_subbar_sec") if _wsub not in (None, ""): params["whipsaw_subbar_sec"] = int(float(_wsub)) @@ -10094,6 +10094,38 @@ def api_optuna_stop(job_id: str): return jsonify({"ok": False, "error": str(e)}), 400 +@app.route("/api/optuna/delete/", methods=["DELETE", "POST"]) +def api_optuna_delete(job_id: str): + """개별 잡 삭제 (JSON + orphan tmp + refine_state 사이드카). 로그·원본 CLI 결과는 보존. + + 실행 중 잡은 거부 (먼저 /api/optuna/stop 호출 후 재시도). + """ + from kis_trader.backtest import optuna_web_jobs as owj + + try: + return jsonify(owj.delete_job(job_id)) + except FileNotFoundError: + return jsonify({"ok": False, "error": "없는 job_id"}), 404 + except RuntimeError as e: + return jsonify({"ok": False, "error": str(e)}), 409 + except Exception as e: + return jsonify({"ok": False, "error": str(e)}), 400 + + +@app.route("/api/optuna/delete_bulk", methods=["POST"]) +def api_optuna_delete_bulk(): + """조건 기반 대량 삭제. body: {before: 'YYYY-MM-DDTHH:MM:SS', status: 'error'|'done'}.""" + from kis_trader.backtest import optuna_web_jobs as owj + + body = request.get_json(silent=True) or {} + before = body.get("before") + status = body.get("status") + try: + return jsonify(owj.delete_jobs_bulk(before=before, status=status)) + except Exception as e: + return jsonify({"ok": False, "error": str(e)}), 400 + + @app.route("/api/optuna/ob_bias_hints", methods=["GET"]) def api_optuna_ob_bias_hints(): """전략별 최근 Optuna → 호가 ON/OFF 몰림 (웹 폼 녹색 표시용).""" diff --git a/kis_trader/backtest/bt_post_filters.py b/kis_trader/backtest/bt_post_filters.py index 50926b4..925aceb 100644 --- a/kis_trader/backtest/bt_post_filters.py +++ b/kis_trader/backtest/bt_post_filters.py @@ -20,9 +20,9 @@ def apply_bt_post_filters( (옵투나 2차 후처리와 100% 동일한 잣대로 쳐내어 정합성을 맞춥니다) """ ob_enabled = engine_params.get("_orderbook_filter_enabled", False) - whipsaw_enabled = engine_params.get("whipsaw_filter_enabled", False) + whipsaw_filter_enabled = engine_params.get("whipsaw_filter_enabled", False) - if not ob_enabled and not whipsaw_enabled: + if not ob_enabled and not whipsaw_filter_enabled: return trades if not trades: @@ -47,7 +47,7 @@ def apply_bt_post_filters( rejected = False reason = "" - if whipsaw_enabled: + if whipsaw_filter_enabled: ticks = _ticks_for_buy(db, code, buy_dt, market=market) if not ticks: continue @@ -68,11 +68,14 @@ def apply_bt_post_filters( temp_params = dict(engine_params) temp_params["code"] = code temp_params["_eval_dt"] = buy_dt - - rej_reason2 = orderbook_reject_for_entry( + + # 시그니처: orderbook_reject_for_entry(params, strategy, *, current_price, resistance=None) + # 반환: Tuple[Optional[str], Optional[str]] — 반드시 튜플 unpack + # (2026-09-06 수정: price= 오타 → current_price=, 스칼라 대입 → 튜플 unpack) + rej_reason2, _ = orderbook_reject_for_entry( temp_params, strategy, - price=current_price + current_price=current_price, ) if rej_reason2: rejected = True diff --git a/kis_trader/backtest/optuna_web_jobs.py b/kis_trader/backtest/optuna_web_jobs.py index 442ea9a..bf9f5ae 100644 --- a/kis_trader/backtest/optuna_web_jobs.py +++ b/kis_trader/backtest/optuna_web_jobs.py @@ -311,14 +311,28 @@ def _job_path(job_id: str) -> Path: def save_job(meta: Dict[str, Any]) -> None: + """job JSON을 atomic write. + + refine job은 (부모 웹 · refine_runner · reaper) 여러 프로세스가 동일 job을 write하므로, + tmp 파일명에 pid를 붙여 프로세스간 경합을 방지한다. (2026-09-06) + ex) opt_20260906_174618_refine_scal..tmp -> opt_20260906_174618_refine_scal.json + """ _ensure_dirs() jid = str(meta.get("job_id") or "") if not jid: raise ValueError("job_id required") path = _job_path(jid) - tmp = path.with_suffix(".tmp") - tmp.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8") - tmp.replace(path) + tmp = path.with_suffix(f".{os.getpid()}.tmp") + try: + tmp.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8") + tmp.replace(path) + finally: + # 예외로 rename 실패한 경우 orphan tmp 정리 + try: + if tmp.exists(): + tmp.unlink() + except Exception: + pass def load_job(job_id: str) -> Optional[Dict[str, Any]]: @@ -331,6 +345,94 @@ def load_job(job_id: str) -> Optional[Dict[str, Any]]: return None +def delete_job(job_id: str) -> Dict[str, Any]: + """웹 잡 삭제. 실행 중이면 거부. + + - 삭제 대상: `logs/optuna_web_jobs/.json` + orphan `.tmp` + `logs/_refine_state.json` + - 보존: 실행 로그 (logs/optuna_*.log), 원본 CLI 결과 (`results/optuna_*.json`) — 재조사·재import 가능성 + - 실행 중(status=running 또는 pid alive)이면 삭제 거부. stop 후 재시도. + """ + if not job_id or not str(job_id).strip(): + raise ValueError("job_id required") + jid = str(job_id).strip() + meta = load_job(jid) + if meta is None: + raise FileNotFoundError(f"job not found: {jid}") + st = str(meta.get("status") or "").strip().lower() + if st == "running" or _pid_alive(meta.get("pid")): + raise RuntimeError(f"실행 중 job은 삭제 불가 (status={st}, pid={meta.get('pid')}). 먼저 stop 하세요.") + deleted = [] + # 1) job JSON + p = _job_path(jid) + try: + if p.is_file(): + p.unlink() + deleted.append(str(p.name)) + except Exception as e: + raise RuntimeError(f"job JSON 삭제 실패: {e}") + # 2) orphan tmp (pid 접미 포함 — 2026-09-06 이후 형식) + try: + for tmp in JOBS_DIR.glob(f"{jid}.*.tmp"): + try: + tmp.unlink() + deleted.append(tmp.name) + except Exception: + pass + except Exception: + pass + # 3) refine_state 사이드카 + try: + rs = ROOT / "logs" / f"{jid}_refine_state.json" + if rs.is_file(): + rs.unlink() + deleted.append(rs.name) + except Exception: + pass + return {"ok": True, "job_id": jid, "deleted": deleted} + + +def delete_jobs_bulk(*, before: Optional[str] = None, status: Optional[str] = None) -> Dict[str, Any]: + """조건 기반 대량 삭제 (관리자 유틸). + + - before: ISO 문자열. `started_at < before` 인 잡만. + - status: 'error' / 'done' 등. 지정하면 해당 status만. + - 실행 중 잡은 자동 스킵 (에러 없이). + """ + _ensure_dirs() + n_deleted = 0 + n_skipped_running = 0 + n_failed = 0 + deleted_ids: List[str] = [] + for p in JOBS_DIR.glob("*.json"): + try: + m = json.loads(p.read_text(encoding="utf-8")) + except Exception: + continue + st = str(m.get("status") or "").strip().lower() + if st == "running" or _pid_alive(m.get("pid")): + n_skipped_running += 1 + continue + if status is not None and st != str(status).strip().lower(): + continue + if before is not None: + sa = str(m.get("started_at") or "") + if not sa or sa >= before: + continue + try: + delete_job(str(m.get("job_id") or p.stem)) + n_deleted += 1 + deleted_ids.append(str(m.get("job_id") or p.stem)) + except Exception: + n_failed += 1 + return { + "ok": True, + "deleted": n_deleted, + "skipped_running": n_skipped_running, + "failed": n_failed, + "deleted_ids": deleted_ids, + } + + def _job_sort_ts(meta: Dict[str, Any], sort: str = "started") -> float: """ 최근 잡 정렬 키. ※ 파일 mtime 금지 — refresh_job_status 가 폴링마다 save 해서 @@ -476,18 +578,15 @@ def job_list_row(meta: Dict[str, Any]) -> Dict[str, Any]: "tpe_includes_orderbook": (m.get("result_summary") or {}).get("tpe_includes_orderbook"), } + # 엔진 뱃지: use_rust 명시 저장이 진리. 미상은 ❔ (룰 29 · optuna-job-engine-label.mdc) + # 전략명 하드코딩 폴백 금지 — 스캘핑/모멘텀/돌파 Rust 포팅 확장으로 오표시 위험. use_rust = m.get("use_rust") if use_rust is True: out["engine_label"] = "Rust⚡" elif use_rust is False: out["engine_label"] = "Python🐢" else: - # 과거 작업(use_rust 필드가 없는 JSON)을 위한 폴백 로직 - is_tail = "tail" in (m.get("strategy") or "") - if is_tail: - out["engine_label"] = "Rust⚡" - else: - out["engine_label"] = "Python🐢" + out["engine_label"] = "❔" return out @@ -566,6 +665,21 @@ def _spawn_job_reaper( meta["error"] = "process exit_code=%s" % rc save_job(meta) refresh_job_status(meta) + # refine/seq 완료 시 결과 JSON을 자동으로 웹 잡에 등록 → UI 「결과 잡아이디」 노출 (2026-09-06) + # 예전 UX 복원: 사용자가 수동 import 안 해도 refine의 phase1/phase2 결과가 목록에 뜸. + try: + m2 = load_job(job_id) or meta + if str(m2.get("kind") or "") == "mode_refine" and m2.get("status") == "done": + rs = m2.get("refine_state") if isinstance(m2.get("refine_state"), dict) else {} + for k in ("phase1_json", "phase2_json"): + jp = str(rs.get(k) or "").strip() + if jp and Path(jp).is_file(): + try: + register_result_json_as_job(jp, source_label="refine") + except Exception: + pass + except Exception: + pass except Exception: pass @@ -1980,9 +2094,8 @@ def _ob_whip_ui_from_params(params: Optional[Dict[str, Any]]) -> Dict[str, Any]: ob_on = _as_bool_opt(p.get("_orderbook_filter_enabled")) if ob_on is None: ob_on = _as_bool_opt(p.get("ob_filter_enabled")) + # 표준 키 whipsaw_filter_enabled 만 사용 (룰 28 · 2026-09-06 통일). whip_on = _as_bool_opt(p.get("whipsaw_filter_enabled")) - if whip_on is None: - whip_on = _as_bool_opt(p.get("whipsaw_enabled")) def _f(key: str) -> Optional[float]: v = p.get(key) @@ -2468,9 +2581,8 @@ def _overlay_trial_ob_whip_on_ui(ui: Dict[str, Any], params: Dict[str, Any]) -> pg = p.get("pg_filter_enabled") if pg is not None: ui["pg_filter_enabled"] = bool(pg) + # 표준 키 whipsaw_filter_enabled 만 사용 (룰 28 · 2026-09-06 통일). whip = p.get("whipsaw_filter_enabled") - if whip is None: - whip = p.get("whipsaw_enabled") # Legacy fallback for old JSONs if whip is not None: ui["whipsaw_filter_enabled"] = bool(whip) for k in ( @@ -2950,7 +3062,8 @@ def register_result_json_as_job( "started_at": meta.get("started_at") or _now_iso(), "finished_at": meta.get("finished_at") or _now_iso(), "imported_at": _now_iso(), - "use_rust": data.get("use_rust") if data.get("use_rust") is not None else ("tail" in strat), + # import 시에도 명시값만 신뢰. 없으면 None (뱃지=❔). 전략명 하드코딩 폴백 금지 (룰 29). + "use_rust": data.get("use_rust"), }) meta["result_summary"] = _summarize_result_json(str(path)) try: diff --git a/kis_trader/backtest/param_search_apply_snapshot.py b/kis_trader/backtest/param_search_apply_snapshot.py index 1020c5b..59ed2cb 100644 --- a/kis_trader/backtest/param_search_apply_snapshot.py +++ b/kis_trader/backtest/param_search_apply_snapshot.py @@ -269,8 +269,11 @@ def _patch_from_momentum_merged(m: Dict[str, Any]) -> Dict[str, str]: def _whipsaw_combo_to_env_patch(strategy: str, m: Dict[str, Any]) -> Dict[str, str]: - """TPE/Grid merged 의 whipsaw_* → {STRAT}_WHIPSAW_* (돌파·꼬리 스킵은 호출측).""" - if "whipsaw_filter_enabled" not in m and "whipsaw_enabled" not in m and "whipsaw_subbar_sec" not in m: + """TPE/Grid merged 의 whipsaw_* → {STRAT}_WHIPSAW_* (돌파·꼬리 스킵은 호출측). + + 표준 키 whipsaw_filter_enabled 만 사용 (룰 28 · 2026-09-06 통일). + """ + if "whipsaw_filter_enabled" not in m and "whipsaw_subbar_sec" not in m: return {} from kis_trader.backtest.optuna_whipsaw_recommend import _whipsaw_env_prefix @@ -278,8 +281,8 @@ def _whipsaw_combo_to_env_patch(strategy: str, m: Dict[str, Any]) -> Dict[str, s if not pfx: return {} out: Dict[str, str] = {} - - whip_val = m.get("whipsaw_filter_enabled") if "whipsaw_filter_enabled" in m else m.get("whipsaw_enabled") + + whip_val = m.get("whipsaw_filter_enabled") if whip_val is not None: on = str(whip_val).strip().lower() in ("1", "true", "yes", "on") out[f"{pfx}_WHIPSAW_FILTER_ENABLED"] = "true" if on else "false" diff --git a/kis_trader/backtest/param_search_scalping.py b/kis_trader/backtest/param_search_scalping.py index 5e4e2c5..3481da2 100644 --- a/kis_trader/backtest/param_search_scalping.py +++ b/kis_trader/backtest/param_search_scalping.py @@ -729,10 +729,7 @@ def _get_scalp_field_map(): "SCALP_ORDERBOOK_FILTER_ENABLED", lambda v: "true" if v in (True, 1, "1", "true", "True", "on", "ON") else "false", ), - "whipsaw_enabled": ( - "SCALP_WHIPSAW_FILTER_ENABLED", - lambda v: "true" if v in (True, 1, "1", "true", "True", "on", "ON") else "false", - ), + # 표준 키 whipsaw_filter_enabled 만 사용 (룰 28 · 2026-09-06 통일). "whipsaw_filter_enabled": ( "SCALP_WHIPSAW_FILTER_ENABLED", lambda v: "true" if v in (True, 1, "1", "true", "True", "on", "ON") else "false", diff --git a/scratch/test_whip.py b/scratch/test_whip.py index 93e511a..16acd74 100644 --- a/scratch/test_whip.py +++ b/scratch/test_whip.py @@ -3,8 +3,8 @@ sys.path.append("/home/hoon/kis_bot") from kis_trader.backtest.optuna_web_jobs import _ob_whip_ui_from_params params = { - "whipsaw_enabled": True, - "whipsaw_filter_enabled": None, + # 2026-09-06 통일: whipsaw_enabled 옛 alias 폐기, whipsaw_filter_enabled 단일 (룰 28). + "whipsaw_filter_enabled": True, "whipsaw_subbar_sec": 45, "whipsaw_lookback_sec": 120, "whipsaw_dip_pct": 0.001 diff --git a/scripts/compare_momentum_mode_ob100_whip.py b/scripts/compare_momentum_mode_ob100_whip.py index a7b89da..a7f1a1e 100644 --- a/scripts/compare_momentum_mode_ob100_whip.py +++ b/scripts/compare_momentum_mode_ob100_whip.py @@ -132,7 +132,7 @@ def main() -> int: # A) 타점만 (호가·휩쏘 OFF) a = dict(mode_params) a["_orderbook_filter_enabled"] = False - a["whipsaw_enabled"] = False + a["whipsaw_filter_enabled"] = False print("[3A] mode 타점만 (호가OFF·휩쏘OFF)", flush=True) cases.append(_pack("A_mode_only", _eval(a), "호가OFF·휩쏘OFF")) @@ -143,13 +143,13 @@ def main() -> int: b["_ob_min_bid_ask_ratio"] = float(ep["orderbook_min_bid_ask_ratio"]) if ep.get("orderbook_entry_ask_max_mult") is not None: b["_ob_ask_max_mult"] = float(ep["orderbook_entry_ask_max_mult"]) - b["whipsaw_enabled"] = False + b["whipsaw_filter_enabled"] = False print("[3B] mode + 100진입호가 (휩쏘OFF)", flush=True) cases.append(_pack("B_mode_ob100", _eval(b), "호가100 ON · 휩쏘OFF")) # C) +100 + 휩쏘 c = dict(b) - c["whipsaw_enabled"] = True + c["whipsaw_filter_enabled"] = True if wp.get("whipsaw_subbar_sec") is not None: c["whipsaw_subbar_sec"] = int(wp["whipsaw_subbar_sec"]) if wp.get("whipsaw_lookback_sec") is not None: diff --git a/scripts/mom_bt_one_day_trial120.py b/scripts/mom_bt_one_day_trial120.py index 01c09e2..876aa43 100644 --- a/scripts/mom_bt_one_day_trial120.py +++ b/scripts/mom_bt_one_day_trial120.py @@ -33,7 +33,7 @@ def main() -> int: print( "trial120 ob=%s whip=%s sl=%s tp=%s" % ( combo.get("_orderbook_filter_enabled"), - combo.get("whipsaw_enabled"), + combo.get("whipsaw_filter_enabled"), combo.get("sl_pct"), combo.get("tp_pct"), ), diff --git a/static/js/backtest.js b/static/js/backtest.js index 0d1cdba..f4c4578 100644 --- a/static/js/backtest.js +++ b/static/js/backtest.js @@ -4642,9 +4642,9 @@ function fillObWhipReadonly(prefix, s) { : (s.min_bid_ask_ratio != null ? s.min_bid_ask_ratio : s.orderbook_min_bid_ask_ratio); const ask = s.ob_ask_max_mult != null ? s.ob_ask_max_mult : (s.ask_max_mult != null ? s.ask_max_mult : s.orderbook_entry_ask_max_mult); + // 표준 키 whipsaw_filter_enabled 만 사용 (룰 28 · 2026-09-06 통일). let whipOn = false; if (s.whipsaw_filter_enabled != null) whipOn = s.whipsaw_filter_enabled; - else if (s.whipsaw_enabled != null) whipOn = s.whipsaw_enabled; on(`${p}_ob_ro_on`, obOn); num(`${p}_ob_ro_spread`, spread); num(`${p}_ob_ro_ratio`, ratio); @@ -4678,9 +4678,9 @@ function optunaObWhipOverridesFromParams(p) { if (!p || typeof p !== 'object') return {}; const obOn = p.ob_filter_enabled != null ? p.ob_filter_enabled : p._orderbook_filter_enabled; const pgOn = p.pg_filter_enabled != null ? p.pg_filter_enabled : p._program_filter_enabled; + // 표준 키 whipsaw_filter_enabled 만 사용 (룰 28 · 2026-09-06 통일). let whipOn = false; if (p.whipsaw_filter_enabled != null) whipOn = p.whipsaw_filter_enabled; - else if (p.whipsaw_enabled != null) whipOn = p.whipsaw_enabled; const spread = p.max_spread_pct != null ? p.max_spread_pct : p._ob_max_spread_pct; const ratio = p.ob_min_bid_ask_ratio != null ? p.ob_min_bid_ask_ratio : (p.min_bid_ask_ratio != null ? p.min_bid_ask_ratio : p._ob_min_bid_ask_ratio); @@ -4776,9 +4776,9 @@ function applyOptunaObWhipToHiddenInputs(p, prefix) { }[px]; if (!hid) return; const set = (id, v) => { if ($(id) && v !== undefined && v !== null && v !== '') $(id).value = v; }; + // 표준 키 whipsaw_filter_enabled 만 사용 (룰 28 · 2026-09-06 통일). let whipOn = false; if (p.whipsaw_filter_enabled != null) whipOn = p.whipsaw_filter_enabled; - else if (p.whipsaw_enabled != null) whipOn = p.whipsaw_enabled; if (whipOn != null) set(hid.whip, whipOn ? '1' : '0'); set(hid.sub, p.whipsaw_subbar_sec); set(hid.lb, p.whipsaw_lookback_sec); @@ -10907,6 +10907,21 @@ async function optunaStop() { } catch (e) { alert('오류: ' + e); } } +// 잡 삭제 (2026-09-06 신설) — job JSON + orphan tmp + refine_state 사이드카만 삭제. +// 실행 로그(logs/optuna_*.log), 원본 CLI 결과(results/optuna_*.json)는 보존 → 재조사·재import 가능. +async function optunaDelete(jobId) { + if (!jobId) return; + if (!confirm(`잡 [${jobId}] 을 삭제할까요?\n- 잡 메타 JSON 삭제\n- 로그 파일과 원본 결과 JSON은 보존됩니다.`)) return; + try { + const r = await fetch('/api/optuna/delete/' + encodeURIComponent(jobId), { method: 'DELETE' }); + const j = await r.json(); + if (!j.ok) { alert('삭제 실패: ' + (j.error || '알수없음')); return; } + // 현재 선택 잡이 삭제된 것이면 선택 해제 + if (_optunaJobId === jobId) { _optunaJobId = null; } + optunaRefreshJobs(); + } catch (e) { alert('오류: ' + e); } +} + function optunaFormatModeTopRow(t, i) { const dAvg = (t.period_daily_avg_pnl != null) ? t.period_daily_avg_pnl : t.daily_pnl_mean; const dPct = (t.period_daily_avg_pct != null) ? t.period_daily_avg_pct : t.daily_avg_pct; @@ -10992,6 +11007,10 @@ async function optunaRefreshJobs() { ? `` + `` : ''; + // 잡 삭제 버튼 (실행 중이 아닐 때만 노출) — 2026-09-06 신설 + const delBtn = (st !== 'running') + ? `` + : ''; const runParams = []; // 승리식: sort_by 필드 없으면 표시 생략 (구잡) @@ -11035,6 +11054,7 @@ async function optunaRefreshJobs() { ${extraBtns} + ${delBtn} `; }).join('');