196 lines
12 KiB
Markdown
196 lines
12 KiB
Markdown
# 스캘핑 백테스트 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분별 “실제 후보” 목록을 반환합니다.
|
||
(추후 백테스트에서 “저장된 유니버스 우선 사용” 옵션을 넣을 때 이 메서드를 쓰면 됩니다.)
|