본문 바로가기
inpilot.dev

백테스트와 실계좌가 안 맞는다 — 괴리를 숫자로 잡기

2026-08-10 · 3 min read

$ diff <(signals bt) <(signals live)

신호 12/12 · 슬리피지 +7.3bp

"실전이 백테스트보다 나쁘다"로는 아무것도 못 고친다

원인 후보가 너무 많다. 슬리피지일 수도, 신호가 다르게 나온 걸 수도, 주문이 아예 안 나간 걸 수도 있다. 감상으로는 좁혀지지 않는다.

그래서 같은 형식으로 로그를 남기고 행 단위로 대조하는 걸 만들었다. 이게 지금까지 만든 것 중 제일 효율이 좋았다.

두 경로가 같은 스키마로 로그를 남긴다

CREATE TABLE run_log (
  run_id     TEXT NOT NULL,        -- 'bt:2026-08-10' | 'live:2026-08-10'
  ts         TIMESTAMPTZ NOT NULL,
  symbol     TEXT NOT NULL,
  stage      TEXT NOT NULL,        -- signal | order | fill
  side       TEXT,
  qty        BIGINT,
  price      BIGINT,
  meta       JSONB,
  PRIMARY KEY (run_id, ts, symbol, stage)
);

핵심은 백테스트 엔진과 실매매 봇이 같은 함수로 기록하는 것이다. 형식이 다르면 대조 자체가 안 된다.

그리고 실매매와 같은 날짜로 백테스트를 매일 자동 재실행한다. 장 마감 후 배치로 돌리고 두 로그를 비교한다.

3단계로 좁힌다

괴리는 세 지점 중 하나에서 생긴다. 순서대로 확인하면 범위가 좁혀진다.

1단계: 신호가 같은가

SELECT symbol, ts FROM run_log WHERE run_id = 'bt:2026-08-10' AND stage='signal'
EXCEPT
SELECT symbol, ts FROM run_log WHERE run_id = 'live:2026-08-10' AND stage='signal';

여기서 차이가 나면 데이터 문제다. 전략 코드는 같은데 입력이 다른 것이다. 실제로 나왔던 원인들:

  • 실시간 시세와 백테스트용 과거 데이터의 거래량 정의가 달랐다
  • 실시간에는 아직 안 들어온 종목이 있었다(신규 상장 반영 지연)
  • 백테스트가 수정주가를 쓰고 실매매가 원본 가격을 썼다

신호가 다른데 아래 단계를 파는 건 시간 낭비다. 여기부터 맞춘다.

2단계: 주문이 나갔는가

신호는 같은데 주문이 없으면 리스크 레이어나 API에서 막힌 것이다.

SELECT s.symbol, s.ts
FROM run_log s
LEFT JOIN run_log o
  ON o.run_id = s.run_id AND o.symbol = s.symbol AND o.stage = 'order'
WHERE s.run_id = 'live:2026-08-10' AND s.stage = 'signal' AND o.symbol IS NULL;

여기 걸리는 건 대개 정상이다. 일일 손실 한도, 포지션 개수 초과, 유동성 필터. 다만 왜 막혔는지가 로그에 있어야 정상인지 판단이 된다. 그래서 차단할 때 meta에 사유를 남긴다.

log(stage="signal", symbol=s, meta={"blocked_by": "daily_loss_limit"})

3단계: 체결가가 얼마나 다른가

여기가 진짜 슬리피지다. 앞의 둘이 깨끗해야 이 숫자가 의미를 갖는다.

SELECT b.symbol,
       l.price - b.price                         AS diff,
       (l.price - b.price) * 10000.0 / b.price   AS bps
FROM run_log b JOIN run_log l USING (symbol, ts)
WHERE b.run_id='bt:2026-08-10' AND l.run_id='live:2026-08-10'
  AND b.stage='fill' AND l.stage='fill';

bps 분포를 매일 쌓으면 백테스트에 넣을 슬리피지 값이 추정치가 아니라 실측치가 된다. 처음엔 가정으로 5bp를 넣었는데, 실측해보니 유동성 낮은 종목에서 30bp가 넘었다. 그 종목군을 유니버스에서 뺐다.

매일 한 줄로 요약한다

2026-08-10  신호 12/12 일치  주문 10/12(2건 한도)  평균 슬리피지 +7.3bp  손익차 -0.11%

이 한 줄을 매일 기록한다. 어느 날 갑자기 값이 튀면 그날 뭔가 바뀐 것이다. 전략을 고쳤거나, 데이터 소스가 바뀌었거나, 증권사 API가 바뀌었거나.

숫자를 쌓기 전에는 "요즘 좀 이상한데"라고만 느꼈고, 쌓고 나서는 "8월 3일부터 신호 불일치가 늘었다"고 말할 수 있게 됐다. 그 날짜에 뭘 배포했는지 보면 원인은 대개 바로 나온다.

페이퍼 트레이딩을 중간에 둔다

백테스트 → 실계좌로 바로 가지 않는다. 사이에 페이퍼 트레이딩을 넣으면 주문 경로 전체를 실제로 태우면서 돈은 안 잃는다.

백테스트 (과거 데이터, 가상 체결)
  ↓  신호 일치 확인
페이퍼 (실시간 데이터, 가상 체결)
  ↓  주문 경로·리스크 레이어 확인
실계좌 소액
  ↓  슬리피지 실측
실계좌

페이퍼 단계에서 잡은 버그가 제일 많았다. 실시간 데이터가 들어오는 환경에서만 드러나는 것들 — 장 시작 직후 호가 공백, 거래정지 종목 처리, 재연결 중 신호 유실 — 이 전부 여기서 나왔다.

검증

def test_bt_live_signal_parity():
    """같은 날 신호 집합이 일치해야 한다. 불일치는 데이터 문제 신호"""
    bt = signals("bt:2026-08-10")
    live = signals("live:2026-08-10")
    assert bt == live, f"only_bt={bt-live} only_live={live-bt}"

이걸 매일 자동으로 돌리고 실패하면 알림을 받는다. 전략 성과 지표보다 이 테스트가 먼저 깨진다. 그리고 대개 성과가 나빠지는 원인이 여기 있다.

한 줄 요약

두 경로가 같은 스키마로 로그를 남기게 하고, 신호 → 주문 → 체결 순으로 대조해 원인을 한 단계씩 좁힌다.

새 글이 올라오면 받아보기

스팸 없이, 새 글이 올라올 때만 보내드려요.

댓글

댓글은 giscus 설정 후 표시됩니다. (docs/SETUP-features.md 참고)