Files
kis_trader/docs/SCALP_BACKTEST_VS_LIVE.md
Hwang f61c471aac 브랜치 분리 방식: A / B / C
A 선택 시 커밋 메시지: 위 초안 OK / 수정 / 직접 작성
작업 시점: 지금 / 운영 데이터 1~2일 쌓고 / 주말
2026-05-05 21:04:17 +09:00

12 KiB
Raw Permalink Blame History

스캘핑 백테스트 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분봉이 하나라도 있는 모든 종목을 사용합니다.
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봉 기준으로 검사한다”**는 차이 때문입니다.
  • 백테스트 결과를 실매매에 가깝게 맞추려면:
    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.pycandidates = 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분별 “실제 후보” 목록을 반환합니다.
    (추후 백테스트에서 “저장된 유니버스 우선 사용” 옵션을 넣을 때 이 메서드를 쓰면 됩니다.)