# 스캘핑 백테스트 vs 실매매: 같은 날 백테 5건 / 실시간 0건 원인

---

## 데이터 소스: ws_candles

- **스캘핑 백테스트 / 파람서치**는 모두 **ws_candles** 테이블에서 **timeframe=1 (1분봉)** 만 조회해서 씁니다.
- 예: `SELECT ... FROM ws_candles WHERE timeframe=1 AND code=%s AND candle_time >= ? AND candle_time <= ? AND is_confirmed=1`
- 따라서 **대시보드에 나온 1건(369370 12:53 익절)** 같은 결과는, 이 1분봉 SELECT 결과를 엔진에 넣어 돌린 백테스트 결과가 맞습니다. (같은 테이블, 1분봉만 사용.)

**ws_candles 컬럼 중 숫자 의미**

| 컬럼 | 의미 |
|------|------|
| **timeframe** | 봉 간격. **1** = 1분봉(스캘핑용), **3** = 3분봉(꼬리잡기용). |
| **rsi_2, rsi_3, rsi_5** | 기간 2·3·5로 **미리 계산해 둔 RSI** (대시/봇용). 스캘핑 백테스트는 이 컬럼을 쓰지 않고, **close만** 가져와서 엔진에서 `compute_rsi_series(closes, rsi_period)` 로 다시 계산합니다. |
| **RSI 기간 3** (UI) | RSI를 **몇 개 봉**으로 계산할지. 3이면 직전 3봉 종가로 RSI(3) 계산. |

- 보여주신 샘플 행(`369370, 3, 202603171430, ...`)은 **timeframe=3** 이라서 **3분봉** 데이터입니다. 스캘핑 백테스트에는 **timeframe=1** 인 1분봉만 사용됩니다.

---

## 가상 거래 내역(최근 200건)이 나오는 경로

대시보드의 **가상 거래 내역**은 DB 테이블을 직접 조회한 것이 아니라, 아래처럼 **같은 1분봉으로 백테스트를 돌린 결과**를 그대로 보여줍니다.

| 단계 | 파일 | 내용 |
|------|------|------|
| 1. 캔들 로드 | **backtest_web.py** (스캘핑 API) | 테이블 **ws_candles** 에서 `timeframe=1`, `is_confirmed=1`, 기간 내 행 SELECT → `codes_candles` |
| 2. 백테스트 실행 | **scalping_engine.run_scalping_backtest()** | `codes_candles`와 params로 봉 단위 시뮬레이션 → **all_trades** 리스트 생성 (각 건에 `rsi_entry` 포함) |
| 3. 응답·표시 | **backtest_web.py** | `all_virtual_trades = se.run_scalping_backtest(...)` 반환값을 `trades: all_virtual_trades[-200:]` 로 프론트에 전달 → "가상 거래 내역 (최근 200건)" 테이블로 렌더링 |

**진입RSI** 값의 출처:

- **scalping_engine.py** `run_scalping_backtest()` 안에서, **매수 신호가 난 봉(신호 봉)** 의 RSI를 `position["rsi"]`에 넣고, 청산 시 `all_trades.append(..., "rsi_entry": round(position["rsi"], 1))` 로 기록합니다.
- 즉 **진입RSI**는 “진입한 시각의 봉”이 아니라 **“신호가 나서 다음 봉 시가에 매수하기로 한, 그 직전(신호) 봉”** 의 RSI입니다.  
  예: 12:53 진입 → 신호 봉은 12:52 봉 → 표시되는 진입RSI는 12:52 봉 기준 RSI(3) 값(예: 14.1).  
  RSI 과매도 조건이 17 이하이면 14.1은 조건을 만족합니다.

---

## 풀백 시 동작 (유니버스)

1. **캔들**  
   항상 **ws_candles, timeframe=1, is_confirmed=1** 인 1분봉만 조회해서 씀. (변경 없음.)
2. **유니버스(어떤 종목을 매수 검사할지)**  
   - 먼저 **build_universe_simulation()** 으로 1분봉만으로 5분마다 후보 슬롯을 만들어 둠.  
   - 그다음 **target_candidates_history** 를 조회해서, 해당 기간에 저장된 이력이 있으면 **그 슬롯만** 이력으로 덮음.  
   - 최종적으로 나온 `universe_by_slot` 으로 백테스트 실행 (해당 슬롯에 있는 종목만 매수 신호 검사).

즉, **풀백**은 “캔들 소스”가 아니라 **“유니버스(후보 종목 목록)”** 에만 적용됩니다.  
캔들은 항상 ws_candles 1분봉, 유니버스만 이력 있으면 이력 → 없으면 시뮬레이션입니다.

---

## 현상

- **날짜**: 2026-03-18
- **조건**: RSI(3) 과매도 17, 손절 0.8%, 익절 2.5% 등 동일
- **백테스트(backtest_web)**: 거래 **5건** (076610, 263750, 412350, 369370, 041910)
- **실시간 봇(kis_scalping_ver2)**: 거래 **0건** (로그상 RSI 17 이하인 종목 없음)

---

## 원인 요약

| 구분 | 백테스트 (backtest_web) | 실시간 봇 (kis_scalping_ver2) |
|------|-------------------------|-------------------------------|
| **검사 대상** | 그날 `ws_candles` 1분봉이 있는 **전체 종목**(예: 38개) | **target_candidates** 테이블에 있는 종목만 (예: 5~10개) |
| **검사 시점** | 봉 단위로 **모든 봉**을 순회하며 신호 검사 | 매 루프마다 **마지막 1개 봉**만 검사 (`check_buy_signal_live`) |
| **데이터** | 기간 내 `is_confirmed=1` 1분봉 전부 | 실시간 확정봉 + 캔들 수집 타이밍에 따른 1봉 지연 가능 |

백테스트에서 신호가 났던 5종목이 **당일 실시간 봇의 후보 리스트에 없었을 가능성**이 가장 큽니다.

---

## 1. 유니버스(검사 대상) 차이 ⭐ 핵심

### 백테스트

- `backtest_web` 스캘핑 API는 기간 내 **1분봉이 하나라도 있는 모든 종목**을 사용합니다.

```sql
SELECT DISTINCT code FROM ws_candles
WHERE timeframe=1 AND candle_time >= ? AND candle_time <= ?
```

- 따라서 그날 1분봉이 쌓인 **38개 종목 전부**에 대해, 봉 단위로 신호를 검사합니다.
- 076610, 263750, 412350, 369370, 041910는 이 38개 안에 포함되어 있어서 백테스트에서만 신호가 집계됩니다.

### 실시간 봇

- 매수 체크 시 **후보**는 `target_candidates` 테이블에서만 가져옵니다.

```python
candidates = self.db.get_target_candidates()
```

- 이 테이블은 키움 스캐너(또는 별도 스크립트)가 갱신하는 **제한된 종목 리스트**입니다.
- 로그에 "후보 5개 순회", "후보 8개 순회"처럼 나오므로, **당일 특정 시점에 5~10개 정도만** 검사합니다.
- 따라서 **백테스트 38개 ⊃ 실시간 후보 5~10개** 이고, 위 5종목이 그 시점의 후보에 없으면 실시간에서는 한 번도 매수 검사가 되지 않습니다.

→ **같은 날·같은 조건이어도 “누구를 검사하느냐”가 다르기 때문에, 백테스트 5건 / 실시간 0건이 나올 수 있습니다.**

---

## 2. 검사 방식 차이 (봉 단위 vs 마지막 1봉)

### 백테스트 (`run_scalping_backtest`)

- 종목별로 **모든 봉 인덱스 `i`**를 순회합니다.
- 봉 `i`에서 RSI≤17, 이전 봉 음봉 → 현재 봉 양봉, 낙폭 등 조건을 만족하면 **다음 봉(i+1) 시가**에 진입으로 기록합니다.
- 따라서 09:13, 10:03, 11:48, 12:53, 12:57 등 **각 봉이 닫힌 시점을 빠짐없이** 검사합니다.

### 실시간 (`check_buy_signal_live`)

- 넘겨받은 `candles`의 **마지막 1개 봉만** 신호 대상입니다.

```python
i = len(candles) - 1
c = candles[i]
```

- 스캔 주기(예: 30초/1분)에 따라 “방금 확정된 봉”이 1개만 들어오므로, **그 봉이 닫힌 직후 한 번만** 검사합니다.
- 같은 종목이라도:
  - 그 종목이 **후보에 없으면** 아예 검사하지 않고,
  - 후보에 있어도 **확정봉 반영/수집 타이밍**이 1봉 늦으면 RSI 17 이하인 순간을 놓칠 수 있습니다.

---

## 3. 데이터·타이밍

- **백테스트**: 기간 지정 후 한 번 조회하므로, 해당 기간의 **확정된 1분봉 전체**를 사용합니다. (같은 DB라도 나중에 갱신된 값으로 돌릴 수 있음)
- **실시간**: WebSocket/갭보정으로 쌓는 1분봉 + `confirmed_only=True` 등으로 “확정봉만” 사용합니다. 봉이 확정되는 시점이 1분 늦거나, 그때 해당 종목이 후보에 없으면 그 봉은 실시간에서 영원히 검사되지 않습니다.

---

## 정리 및 권장

- **같은 날 백테 5건 / 실시간 0건**의 주된 이유는  
  **“백테스트는 그날 1분봉 있는 38종목 전부를 봉 단위로 검사하고, 실시간은 target_candidates에 있는 소수 종목만 마지막 1봉 기준으로 검사한다”**는 차이 때문입니다.
- 백테스트 결과를 실매매에 가깝게 맞추려면:
  1. **스캘핑 백테스트를 “실제 후보와 동일한 유니버스”로 제한**  
     예: 당일 `target_candidates`에 올라왔던 종목 리스트를 저장해 두고, 백테스트 시 해당 종목만 사용하거나,  
     백테스트 옵션으로 “이 날 이 종목들만” 필터링하는 기능을 두는 방법.
  2. **실매매 후보 수 확대**  
     스캘핑 후보를 더 많이 두면, 백테스트에 나온 종목과 겹칠 확률이 커집니다. (API/부하 trade-off는 별도 검토)
  3. **문서화**  
     “스캘핑 백테스트는 그날 1분봉 있는 전 종목 기준, 실매매는 target_candidates 기준”임을 README나 파라미터 설명에 명시해 두면, 같은 날 차이가 나는 이유를 추후에도 쉽게 이해할 수 있습니다.

---

## 참고 코드 위치

- 백테스트: `backtest_web.py` 스캘핑 API — `SELECT DISTINCT code FROM ws_candles WHERE timeframe=1 ...` → `run_scalping_backtest(codes_candles, params)`
- 실시간 후보: `kis_scalping_ver2.py` — `candidates = self.db.get_target_candidates()` 후 순회
- 실시간 신호: `scalping_engine.check_buy_signal_live(candles, params, state)` — `i = len(candles) - 1` (마지막 1봉만 검사)

---

## 유니버스 시뮬레이션 (2026년 반영)

**백테스트를 실매매와 동일하게 맞추기 위해** 아래가 적용되었습니다.

- **scalping_engine**
  - `build_universe_simulation(codes_candles, top_n=20, min_score=4.0, scan_interval_min=5)`  
    과거 1분봉만으로 5분마다 ‘강도(낙폭·회복률) 순 상위 N종목’ 유니버스를 계산.
  - `run_scalping_backtest(..., universe_by_slot=universe_by_slot)`  
    `universe_by_slot`이 있으면 **해당 슬롯의 후보 종목에서만** 매수 신호 검사.
- **backtest_scalping/param_search.py**  
  유니버스 **풀백**: 먼저 `get_universe_history_for_backtest(start_ymd, end_ymd)`로 저장된 이력 조회 → 있으면 해당 슬롯은 이력으로 덮고, 없거나 조회 실패 시 `build_universe_simulation` 결과만 사용.  
  `UPDATE_UNIVERSE_TOP_N`, `UPDATE_UNIVERSE_MIN_SCORE` 환경변수로 시뮬레이션 시 상위 N·최소 점수 설정.
- **backtest_web.py** 스캘핑 API  
  동일 **풀백**: 시뮬레이션으로 `universe_by_slot` 생성 후, `get_universe_history_for_backtest`로 이력 조회해 있으면 `universe_by_slot.update(history)` 적용 후 `run_scalping_backtest(..., universe_by_slot=...)` 호출.  
  쿼리 파라미터 `universe_top_n`, `universe_min_score` 또는 env로 조정 가능.

이제 백테스트는 **저장된 이력이 있으면 그걸 쓰고, 없으면 1분봉 시뮬레이션**으로 5분마다 후보를 갈아 끼우는 실매매와 동일한 유니버스로 동작합니다.

---

## 후보 이력 적재 (target_candidates_history)

`target_candidates`는 매 5분마다 **DELETE 후 INSERT**라서 과거 시점의 후보 목록이 남지 않습니다.  
그래서 백테스트는 위처럼 **1분봉으로 유니버스를 시뮬레이션**해 사용합니다 (이력 없이도 가능).

동시에 **“실제 그 시각에 봇이 보던 후보”를 남겨 두고 싶다면** 이력을 쌓을 수 있습니다.

- **target_candidates_history** 테이블  
  스캐너가 `update_target_candidates()`를 호출할 때마다, 같은 시각의 `slot_key`(5분 단위)와 함께 **INSERT**로 적재됩니다. (기존 target_candidates는 계속 DELETE 후 INSERT로 현재만 유지.)
- **쌓는 주기**  
  스캐너가 5분마다 돌 때마다 자동으로 한 슬롯분이 쌓입니다. 별도 설정 없이 **계속 쌓이면** 나중에 그 기간 백테스트 가능.
- **조회**  
  `TradeDB.get_universe_history_for_backtest(start_ymd, end_ymd)`  
  → `{ slot_key: [code, ...] }` 형태로, 해당 기간의 5분별 “실제 후보” 목록을 반환합니다.  
  (추후 백테스트에서 “저장된 유니버스 우선 사용” 옵션을 넣을 때 이 메서드를 쓰면 됩니다.)
