# 백테스트·파라서치·웹 정렬 — 최종 단계 코드 정리 (2026-05-30) > **대상:** 꼬리(SHORT) 정렬 작업에서 여러 번 잘못 수정한 뒤 도달한 **최종 패턴**. > **목적:** 모멘텀·돌파·스캘핑 등 **다른 전략**에 같은 실수 없이 적용하기 위한 체크리스트. 관련 문서: [TAIL_BACKTEST_WEB_VS_PARAM_SEARCH.md](./TAIL_BACKTEST_WEB_VS_PARAM_SEARCH.md) (꼬리 도메인 상세) --- ## 0. 최종 아키텍처 (한 줄) ``` 단일 엔진 (tail_engine / scalping_engine / breakout) ↑ 공통 백테 로더 (*_backtest_common.py) ← 웹 API + param_search 둘 다 ↑ get_*_defaults_from_db() ← env 단일 소스 ``` **금지:** 웹 API만 별도 루프·별도 손익식·폼 숫자만 params에 넣기. --- ## 1. 신규/수정 파일 요약 | 파일 | 상태 | 역할 | |------|------|------| | `kis_trader/backtest/tail_backtest_common.py` | **신규** | 유니버스·캔들·손익·`run_tail_backtest_web_aligned` | | `kis_trader/backtest/tail_param_search.py` | **수정** | fast 그리드 확장, `tbc` 경유, `--apply` 그리드만 반영 | | `kis_trader/engine/tail_engine.py` | 기존 | `get_tail_defaults_from_db`, `run_tail_backtest`, V4 청산 | | `backtest_web.py` | **수정** | TRIGGER 플래그 병합, 거래 정렬, 꼬리 API → `tbc` | --- ## 2. 코드 변경 상세 ### 2.1 `tail_backtest_common.py` (다른 전략 템플릿) **해야 할 것:** | 함수 | 책임 | |------|------| | `date_keys(start, end)` | `YYYY-MM-DD` → `candle_time` 키 | | `resolve_*_universe(...)` | `target_candidates_history` / 전종목 — **웹과 동일 쿼리** | | `load_*_candles_by_code(...)` | `ws_candles` SQL — **웹과 byte 단위 동일** | | `attach_*_trade_pnl(...)` | 수수료·세금·보유분 — **웹 jsonify와 동일식** | | `run_*_backtest_web_aligned(...)` | `engine.run_*_backtest` 1회 + 손익 부착 | | `fee_and_slot_from_env_row(...)` | `FEE_RATE_PCT`, `SLOT_MONEY_DEFAULT` | **꼬리 구현:** ```python # kis_trader/backtest/tail_backtest_common.py trades = te.run_tail_backtest(candles_by_code, engine_params, universe_by_slot=...) attach_tail_trade_pnl(trades, slot_money=..., fee_rate=..., sell_tax=...) ``` **다른 전략 적용 시:** `momentum_backtest_common.py`, `breakout_backtest_common.py` 등 **전략당 1파일**, 엔진 import만 교체. --- ### 2.2 `tail_param_search.py` #### fast 그리드 (최종) ```python "fast": { "min_drop_rate": [0.02, 0.03], "min_recovery_ratio": [0.4, 0.45, 0.5], "tail_ratio_min": [1.0, 1.5], "max_rec_3m": [0.85, 0.9], "shoulder_min_high": [0.003, 0.005], "shoulder_cut_pct": [0.002, 0.003], "stop_atr_mult": [1.5, 2.0], "target_atr_mult": [1.5, 2.0], "atr_tp_max_pct": [0.8, 1.0], } # FAST_MAX_COMBOS = 768 (2×3×2×2×2×2×2×2×2) ``` #### base_params (그리드 **밖**, DB 고정) ```python base_params = te.get_tail_defaults_from_db(db) # + time_start_hm / time_end_hm CLI # + scan_interval_min, force_eod_exit=False ``` **그리드에 넣지 않은 것 (거래 수·정렬에 치명적):** - `skip_hts_scan_dupes`, `use_rsi_filter`, `use_daily_range_filter`, … - `TAIL_SKIP_HTS_SCAN_DUPES` 등 env 플래그 #### 백테 호출 (웹과 동일) ```python trades = tbc.run_tail_backtest_web_aligned( candles_by_code, test_params, universe_by_slot, slot_money=slot_money, fee_rate=fee_rate, sell_tax=sell_tax, ) ``` #### `--apply` (최종 — **그리드 축만** DB 반영) ```python # apply_from_json — target["params"] 우선, apply_cfg 전체 덮어쓰기 금지 p = dict(target.get("params") or {}) env_map = { ... min_drop_rate → MIN_DROP_RATE, shoulder_* → SHOULDER_* ... } snap.update(env_map) # latest snapshot 위에 merge ``` **과거 실수:** `apply_cfg` 전체를 DB에 저장 → 진입 완화값(예: rec 0.25)까지 실매에 박힘. --- ### 2.3 `backtest_web.py` — `/api/backtest/tail` #### (A) TRIGGER 플래그 DB 병합 — **123건 vs 6건 버그 수정** **문제:** 폼 숫자만 `params`에 넣음 → 엔진 `_eval_*` 기본값(True) 적용 → SCAN/TRIGGER 이중 필터 붕괴. **수정:** ```python tail_trigger_flags = { "skip_hts_scan_dupes": _tail_bool_arg(request, "skip_hts_scan_dupes", _def.get(...)), "use_intraday_drop": ..., "use_ma20_filter": ..., "use_rsi_filter": ..., "use_daily_range_filter": ..., "use_high_chase_filter": ..., "bar_chg_min_pct": ..., "bar_chg_max_pct": ..., } params = { ...폼 숫자..., **tail_trigger_flags } ``` 헬퍼: `_tail_bool_arg(request, key, def_val)` — 미전달 시 `_def`(= `get_tail_defaults_from_db`). **응답 JSON `params`에도 `**tail_trigger_flags` 포함** → 웹·파라서치 diff 디버깅용. #### (B) 엔진 경로 + common 모듈 ```python universe_by_slot, universe_source, universe_history_slots, _scan_iv = tbc.resolve_tail_universe(...) candles_by_code, _, _ = tbc.load_tail_candles_by_code(...) all_trades = tbc.run_tail_backtest_web_aligned(...) tail_trades_out = _trades_recent_first(all_trades, 200) ``` **레거시 `else` 분기 (`use_engine=0`)** — 유니버스·V4 청산 미적용. **기본 `use_engine=1` 유지.** #### (C) 가상 거래 200건 정렬 **문제:** `all_trades[-200:]` — 종목 코드 순 append → “최근 200건” 아님. **수정 (Python, 전략 공통):** ```python def _trade_exit_sort_key(t): # exit_time / sell_time / sell_date ... def _trades_recent_first(trades, limit=200): # 매도 시각 내림차순 상위 N ``` **수정 (JS, 모든 가상 거래 테이블):** ```javascript function tradesNewestFirst(trades) { ... } // .reverse() 대신 명시적 정렬 — API가 최신순이어도 프론트에서 재정렬 ``` 적용 API: 실거래 분석, 스캘핑, 꼬리, 돌파, 홀딩, Updow, 모멘텀. #### (D) universe_warning (요약) - 저장 이력 요청했는데 `universe_source=all` → 이력 없음 경고 - 저장 이력 ON + 거래 > 25건 → 플래그/파라미터 불일치 의심 (수정 전 123건 케이스) --- ## 3. 잘못 수정했던 것 — 다시 하지 말 것 | # | 잘못된 접근 | 왜 틀렸나 | 최종 | |---|-------------|-----------|------| | 1 | 웹만 레거시 루프 유지 | 유니버스·엔진 V4와 불일치 | `tbc` + `use_engine=1` | | 2 | 파라서치 `--apply`가 `apply_cfg` 전체 저장 | base 진입값까지 DB 오염 | **`params`(그리드 축)만 env_map** | | 3 | fast 그리드에 진입 완화 하드코딩 (`FAST_BASE_OVERRIDES`) | 웹·실매 env와 불일치 | **제거**, DB base 고정 | | 4 | 웹 params에 TRIGGER 플래그 생략 | `skip_hts` 등 엔진 default → 123건 | **`tail_trigger_flags` merge** | | 5 | `skip_hts`를 그리드에 넣어 768×2 탐색 | 모드 선택이지 미세 �uning 아님 | **env 고정** 또는 정책 결정 후 고정 | | 6 | `trades[-200:]` + `.reverse()` | 종목 순서 기준, 최신 아님 | **`_trades_recent_first`** | | 7 | UI `sl_pct`/`tp_pct`만 맞추면 된다 | V4 청산은 어깨+ATR, UI %는 거의 무관 | **어깨·ATR 축 탐색** | | 8 | 저장 이력 ON인데 거래 수만 보고 “버그” | `skip_hts=False`면 6건이 **정상** | 플래그·응답 JSON 확인 | | 9 | backtest_web 수정 후 **서버 미재시작** | 구 코드로 123건 재현 | **재시작 필수** | --- ## 4. 조심해야 할 것 (최종 체크리스트) ### 4.1 웹 API ↔ param_search ↔ 실매 3-way - [ ] `get_*_defaults_from_db()` 단일 소스 - [ ] 웹 `params` = 폼 숫자 + **DB bool/플래그 전부** (엔진 default에 맡기지 않음) - [ ] 유니버스: `strategy_id`, `history|all`, `scan_interval_min` 동일 - [ ] `time_start_hm` / `time_end_hm` / `timeframe` 동일 - [ ] `slot_money`, `fee_rate`, `sell_tax` 동일 소스 (`fee_and_slot_from_env_row`) - [ ] `force_eod_exit` 의도 확인 (기본 OFF → 며칠 보유 가능) ### 4.2 SCAN vs TRIGGER - [ ] 저장 이력 ON → `skip_hts_scan_dupes=True` **권장** (HTS A 이중 필터 방지) - [ ] DB `TAIL_SKIP_HTS_SCAN_DUPES=False` → 거래 **극소**(6건) — 정책인지 버그인지 명시 - [ ] 무거운 필터는 **param_search 그리드** 또는 **TRIGGER**, SCAN(5분)에는 넣지 않음 ### 4.3 param_search `--apply` - [ ] **그리드 keys → env_map** 화이트리스트만 - [ ] `base_params` 전체 저장 금지 - [ ] 총손익 ≤ 0 결과 apply 스kip (기존 유지) ### 4.4 청산 파라미터 해석 - [ ] UI **손절 3% / 익절 5%** ≠ 실제 1순위 청산 (어깨컷 V4) - [ ] fast/coarse 그리드: **shoulder_*, stop/target_atr_mult, atr_*_pct** - [ ] `sl_pct`는 주로 **포지션 사이징** (`static_sl_pct`) ### 4.5 UI/표시 - [ ] `trades` = `_trades_recent_first(..., 200)` — 매도 시각 기준 - [ ] 프론트 `tradesNewestFirst()` — `.reverse()`만 쓰지 않음 - [ ] 응답 `params`에 플래그 노출 — 재현용 ### 4.6 검증 명령 (꼬리) ```bash python3 kis_trader/backtest/tail_param_search.py \ --start 2026-05-18 --end 2026-05-29 \ --time-start 830 --time-end 1530 \ --min_trades 1 ``` 웹 동일 조건 실행 후: - `total_trades` 일치 (±0) - `params.skip_hts_scan_dupes` 일치 - `universe_source` / `universe_history_slots` 일치 --- ## 5. 다른 전략 적용 로드맵 ### 5.1 공통 패턴 (복사 순서) 1. `{strategy}_backtest_common.py` 생성 (§2.1 함수 세트) 2. `param_search_{strategy}.py`에서 `tbc.run_*_web_aligned` 호출 3. `backtest_web.py` API route: - `_get_*_defaults_for_backtest()` → 엔진 `get_*_defaults_from_db` - `{strategy}_trigger_flags` dict (bool 플래그 **전부** merge) - `tbc` 로더 + `_trades_recent_first` 4. `--apply` env_map **그리드 키만** 매핑 테이블 작성 5. 문서 + 3-way 검증 1회 ### 5.2 전략별 참고 | 전략 | 엔진 | strategy_id | 저장 이력 | 플래그 예시 | |------|------|-------------|-----------|-------------| | 꼬리 SHORT | `tail_engine` | `SHORT` | ✅ 기본 | `skip_hts_scan_dupes`, `use_rsi_filter` | | 모멘텀 | `scalping_engine` | (조건식 id) | 확인 필요 | `MOMENTUM_SKIP_HTS_SCAN_DUPES` | | 돌파 | `breakout` | `BREAKOUT` | `_resolve_backtest_universe` | HTS H 등 | | 스캘핑 | `scalping_engine` | — | optional | `use_defense_filters`, `use_macd_cross` | **모멘텀/스캘핑:** 이미 `get_scalping_defaults_from_db()` 사용 중 — **웹 API에 bool 플래그 merge 여부**만 재점검. **돌파:** `api/backtest/breakout` — `_trades_recent_first` 적용됨, common 모듈 분리는 **미완** → 꼬리 패턴 이식 후보. ### 5.3 env_map 템플릿 (꼬리 `--apply`) ```python # 그리드 params key → env_config column PARAM_TO_ENV = { "min_drop_rate": "MIN_DROP_RATE", "min_recovery_ratio": "MIN_RECOVERY_RATIO_SHORT", "max_rec_3m": "MAX_RECOVERY_RATIO_3M", "tail_ratio_min": "TAIL_RATIO_MIN", "shoulder_min_high": "SHOULDER_MIN_HIGH_PCT", "shoulder_cut_pct": "SHOULDER_CUT_PCT", "stop_atr_mult": "STOP_ATR_MULTIPLIER_TAIL", "target_atr_mult": "TARGET_ATR_MULTIPLIER_TAIL", "atr_tp_max_pct": "TAIL_ATR_TP_MAX_PCT", # ... 그리드에 없는 키는 넣지 않음 } ``` --- ## 6. 청산(V4) — 전략 공통 개념 (꼬리) ``` 1순위 어깨컷 (shoulder_min_high + shoulder_cut_pct) — 변형 트레일링 2순위 ATR 익절 (target) 3순위 ATR 손절 (stop) 4순위 금액손실컷 (max_loss_krw, 어깨 미발동 시) 5순위 장마감 (force_eod_exit=True 일 때만) ``` - UI **tp_pct 5%** 는 2순위까지 **거의 도달 전** 1순위에서 대부분 청산. - **EOD OFF** → 며칠 보유 + 어깨 → 소룩스형 +20% (백테만 해당 가능). --- ## 7. 변경 이력 (코드 기준) | 일자 | 파일 | 변경 | |------|------|------| | 2026-05-30 | `tail_backtest_common.py` | 신규 — 웹·파라서치 단일 진입 | | 2026-05-30 | `backtest_web.py` | `tail_trigger_flags`, `_trades_recent_first`, `tradesNewestFirst` | | 2026-05-30 | `tail_param_search.py` | fast 768조합, `tbc` 경유, apply 그리드만 | | 2026-05-30 | `docs/TAIL_BACKTEST_WEB_VS_PARAM_SEARCH.md` | 꼬리 도메인·skip_hts 상세 | --- ## 8. 빠른 디버깅 **웹 vs CLI 거래 수 다를 때:** 1. `backtest_web` 재시작했는가? 2. 응답 JSON: `skip_hts_scan_dupes`, `universe_source`, `time_start_hm`, `timeframe` 3. CLI: `get_tail_defaults_from_db()` 출력 vs 웹 `params` diff 4. 저장 이력 ON/OFF 동일한가? 5. `force_eod_exit` 동일한가? **거래 표 순서 이상할 때:** - API가 `_trades_recent_first` 쓰는지 - 프론트가 `tradesNewestFirst` 쓰는지 (`.reverse()` 단독 X) --- ## 9. 관련 파일 경로 ``` kis_trader/backtest/tail_backtest_common.py kis_trader/backtest/tail_param_search.py kis_trader/engine/tail_engine.py backtest_web.py # /api/backtest/tail, 유틸 함수 docs/TAIL_BACKTEST_WEB_VS_PARAM_SEARCH.md docs/BACKTEST_ALIGNMENT_FINAL.md # 본 문서 ``` --- *마지막 갱신: 2026-05-30 — 꼬리(SHORT) 정렬 최종 단계 기준.*