# 백테스트·파라서치·웹 정렬 — 최종 단계 코드 정리 (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) 정렬 최종 단계 기준.*
