12 KiB
12 KiB
스캘핑 백테스트 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은 조건을 만족합니다.
풀백 시 동작 (유니버스)
- 캔들
항상 ws_candles, timeframe=1, is_confirmed=1 인 1분봉만 조회해서 씀. (변경 없음.) - 유니버스(어떤 종목을 매수 검사할지)
- 먼저 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분봉이 하나라도 있는 모든 종목을 사용합니다.
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테이블에서만 가져옵니다.
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개 봉만 신호 대상입니다.
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봉 기준으로 검사한다”**는 차이 때문입니다. - 백테스트 결과를 실매매에 가깝게 맞추려면:
- 스캘핑 백테스트를 “실제 후보와 동일한 유니버스”로 제한
예: 당일target_candidates에 올라왔던 종목 리스트를 저장해 두고, 백테스트 시 해당 종목만 사용하거나,
백테스트 옵션으로 “이 날 이 종목들만” 필터링하는 기능을 두는 방법. - 실매매 후보 수 확대
스캘핑 후보를 더 많이 두면, 백테스트에 나온 종목과 겹칠 확률이 커집니다. (API/부하 trade-off는 별도 검토) - 문서화
“스캘핑 백테스트는 그날 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분별 “실제 후보” 목록을 반환합니다.
(추후 백테스트에서 “저장된 유니버스 우선 사용” 옵션을 넣을 때 이 메서드를 쓰면 됩니다.)