
「백테스트는 이기는데 라이브에서는 진다」를 분해하기
요약
백테스트 결과와 실제 라이브 매매 결과가 일치하지 않는 '라이브/BT 괴리' 현상을 분석하고 진단하는 방법을 다룹니다. 괴리를 버그 유래와 구조 유래로 구분하고, '의사 BT(Pseudo-BT)' 메커니즘을 통해 원인을 판정하는 절차를 제시합니다.
핵심 포인트
- 괴리는 버그 유래와 구조 유래(지연, 슬리피지 등)로 구분됨
- 버그는 제거 대상이며, 구조적 차이는 백테스트 모델링 대상임
- 의사 BT를 통해 라이브, 의사 BT, 훈련 BT의 3자 비교 수행
- 라이브와 의사 BT가 다르면 코드나 데이터의 버그로 판단
이 기사는 Zenn에 게시했던 내용을 재게시한 것입니다. 초출: 「백테스트는 이기는데 라이브에서는 진다」를 분해하기
지난 회차까지, 시간의 누수(제1회·미래 참조)와 정보의 누수(제2회·특징량 리크(Feature Leak))를 해결했습니다. 특징량(Feature)도 레이블(Label)도 해당 시점까지 확정된 정보만으로 만들고, 전처리는 분할(Split) 내부에서 fit하며, 겹치는 레이블은 purge했습니다. 데이터도 코드도 이제 올바릅니다.
그럼에도 불구하고, 시스템 트레이딩(System Trade)을 직접 구축해 본 사람이라면 거의 전원이 맞닥뜨리는 벽이 남아 있습니다.
백테스트(Backtest)에서는 예쁜 우상향. 그런데 라이브(Live)로 돌리면 진다.
저도 예외는 아니었습니다. 일본 주식의 주간(장중)에 작동하는 자체 시스템을 개발하고 있었는데, 검증에서는 흠잡을 데 없는 성적이 나오는데 실제로 라이브로 돌리면 체결 내용이 백테스트와 어긋납니다. 이 「라이브/BT 괴리」의 정체를 밝혀내기 위해 결과적으로 수개월을 허비했습니다.
이 기사에서는 그 과정에서 익힌 괴리의 구분 방법을 공유합니다. 개별적인 임계값(Threshold)이나 로직에 대한 이야기는 하지 않겠습니다. 재현성 있는 「진단 절차」만을 작성합니다. 이는 전략이 무엇이든 효과가 있는 이야기입니다.
먼저 결론부터 말씀드리겠습니다. 라이브/BT 괴리에 직면했을 때 가장 해서는 안 될 일은 「전부 버그다」라고 단정 짓고 끊임없이 코드를 쫓는 것과, 반대로 「시장의 탓이다」라며 포기하는 것입니다.
괴리는 성질이 다른 두 종류로 나뉩니다.
| 종류 | 정체 | 있어야 할 상태 | 대처 |
|---|---|---|---|
| 버그 유래 | 코드나 데이터의 불일치 | 제로로 만들 수 있음/해야 함 | 제거함 |
| 구조 유래 | 집행의 물리적인 현실 (지연·해상도·슬리피지(Slippage)) | 제로가 되지 않음 | BT 측에 모델링하여 맞춤 |
이 두 가지를 혼동하면, 제거할 수 있는 버그를 「구조적 차이라 어쩔 수 없다」며 방치하거나, 사라질 리 없는 구조적 차이를 「버그다」라고 생각하며 영원히 고치려 들게 됩니다. 가장 먼저 해야 할 일은 눈앞의 괴리가 어느 쪽인지 판정하는 것입니다.
판정의 열쇠가 되는 것이 제가 「의사 BT(Pseudo-BT)」라고 부르는 메커니즘입니다. 발상은 간단합니다.
통상적인 백테스트(이하, 훈련 BT)는 **깔끔하게 정제된 히스토리컬 데이터(Historical Data)**를 사용합니다. 반면 라이브는 그 순간에 실제로 손에 쥐고 있던 생(Raw) 데이터를 사용하여 작동합니다. 이 둘은 입력 자체가 다르기 때문에 갑자기 대조해 봐도 원인을 구분할 수 없습니다.
그래서 라이브 중에 「그 시각에 실제로 수신한 데이터의 스냅샷(Snapshot)」을 전부 기록해 두었다가, 나중에 라이브와 완전히 동일한 함수·모델에 시계열 순으로 다시 흘려보내는 것. 이것이 의사 BT입니다.
# 라이브 실행 중: 의사결정의 입력을 그대로 기록해 둠
recorded_live_stream.append((now, feed_snapshot))
# 추후: 라이브와 "같은 함수"에 다시 흘려보냄 (이 부분이 중요. BT 전용의 별도 구현을 사용하지 않음)
...
이것으로 3자 비교가 가능해집니다.
- 라이브(본방) -
- 의사 BT(동일 코드 × 기록한 생 데이터) -
- 훈련 BT(동일/별도 코드 × 정제된 히스토리컬)
구분은 이 세 가지 중 어디가 어긋나는지에 따라 결정됩니다.
라이브 ≠ 의사 BT → 코드 경로(Code Path) 혹은 라이브 데이터의 버그 (제거 가능)
라이브 = 의사 BT ≠ 훈련 BT → 구조적 차이 (집행 모델·데이터 전제의 차이)
「라이브와 의사 BT가 어긋난다」면 원인은 자신의 코드 혹은 라이브 특유의 데이터 오염입니다. 이는 반드시 해결할 수 있습니다. 반대로 「라이브와 의사 BT는 일치하는데 훈련 BT와만 어긋난다」면, 그것은 버그가 아니라 집행의 현실이며 쫓아가도 고쳐지지 않습니다.
「라이브 ≠ 의사 BT」 측의 전형적인 사례가 라이브 특유의 데이터 오염입니다. 제가 겪었던 것이 이것이었습니다.
훈련 BT의 히스토리컬은 확정된 봉(Bar)만을 포함합니다. 그런데 라이브 데이터 피드(Data Feed)는 아직 확정되지 않은 봉, 거래량 제로인 플레이스홀더(Placeholder), 배포 사정으로 삽입되는 빈칸 채우기 봉 등을 끝부분에 섞어서 보내올 때가 있습니다.
이것이 특징량 계산에 그대로 들어가면, 라이브에서만 훈련 BT에서는 절대 나올 수 없는 특징량 값을 보고 추론하게 됩니다.
bars = feed.get_bars(symbol) # 끝부분에 미확정/더미가 섞일 수 있음
rsi = compute_rsi(bars["close"], 14) # ← 오염된 시계열로 인해 RSI가 왜곡됨
sig = model.predict(make_features(bars)) # ← 훈련 BT에는 존재하지 않는 입력
문제는 이것이 에러를 발생시키지 않는다는 점입니다. 처리는 끝까지 진행되고, 그럴싸한 시그널 (Signal)이 나옵니다. 그래서 "왜인지 모르겠지만 이기지 못한다"라는 모호한 증상으로만 나타납니다.
진단은 특징량 벡터 (Feature Vector) 대조로 한 번에 해결됩니다. 의사결정 순간에 라이브 (Live)와 유사 BT (Backtest) 각각에서 "모델에 전달한 특징량 벡터 그 자체"를 로그 (Log)하고, 차이를 구합니다.
# 의사결정 순간에, 입력 그 자체를 기록한다
log.debug("decision feat=%s", feat.tolist())
이 부분이 어긋나 있다면, 시그널 운운하기 전에 입력이 다른 것입니다. 원인은 모델이 아니라 데이터 파이프라인 (Data Pipeline)입니다. 대처법은 특징량 계산에 들어가기 전에 미확정 봉 (Unconfirmed Bar)을 검증·제거하여, "라이브의 특징량 벡터 == 유사 BT의 특징량 벡터"를 어서션 (Assertion)으로 보장하는 것입니다. 이 부분이 일치해야 비로소 시그널에 대한 논의를 할 수 있습니다.
한편, "라이브 = 유사 BT ≠ 훈련 BT" 측, 즉 따라가도 고쳐지지 않는 괴리의 대표적인 예는 손절 (Stop Loss, SL)이나 청산 (Exit)의 시간 해상도(Time Resolution)와 체결 지연 (Execution Lag)입니다.
훈련 BT가 1분봉으로 동작한다고 가정해 봅시다. 흔한 구현 방식은 다음과 같습니다.
# 훈련 BT: 분봉의 low가 SL 가격을 하회하면, 해당 봉에서 체결된 것으로 간주한다
if bar.low <= sl_price:
fill_price = sl_price # 딱 SL 가격에서 체결된 것으로 한다
...
하지만 라이브는 다릅니다.
- 가격은 틱 (Tick) 단위로 움직이며, SL 가격에 닿은 순간 시장가 주문이 나간다.
- 주문이 호가창 (Order Book)에 도달하기까지 수백 ms ~ 수 초의 **지연 (Latency)**이 있다.
- 체결 가격은 SL 가격과 정확히 일치하는 것이 아니라, 슬리피지 (Slippage)가 발생한 가격이다.
즉, 훈련 BT는 "분봉의 저가와 정확히 일치하는 시점에, 봉 확정 시각에 체결"된다고 가정하는 반면, 라이브는 "틱으로 닿는 순간 발주하고, 지연과 슬리피지를 동반하여 체결"됩니다. 체결 가격도 시각도 구조적으로 다른 것입니다.
이것은 버그가 아닙니다. 시장과 발주의 물리적인 현실입니다. 그렇기 때문에 유사 BT(=라이브와 동일한 생데이터·동일한 코드)와는 일치하지만, 정제된 데이터를 전제로 하는 훈련 BT와만 차이가 발생합니다. 이것을 "버그다"라고 생각해서 코드를 고치려 해도 영원히 고쳐지지 않습니다.
올바른 대처는 훈련 BT 측을 현실에 가깝게 만드는 것입니다. 구체적으로는 다음과 같습니다.
- 체결 지연을 시간으로서 주입한다 (터치 후 일정 지연 후에 체결 판정)
- SL/Exit의 판정 해상도를 라이브의 감시 빈도에 맞춘다
- 체결 가격에 슬리피지 모델을 적용한다 (호가 단위로 반올림/내림)
이렇게 하면 훈련 BT의 성적은 떨어집니다. 하지만 그것이 진정한 기대값입니다. 괴리를 없애는 것이 아니라, 괴리만큼 훈련 BT를 비관적으로 만들어 라이브와 정합성을 맞추는 것이 목적입니다.
괴리를 발견했다면, 위에서부터 순서대로 진행하세요.
-
유사 BT를 준비한다 — 라이브의 입력 스냅샷을 기록하고, 동일한 코드로 재생한다. 이것이 없으면 아무것도 분리해낼 수 없다.
-
특징량 벡터를 3자 대조한다 — 라이브 vs 유사 BT가 어긋나면 거기서 멈추고 데이터 파이프라인을 고친다 (버그 유래).
-
라이브 = 유사 BT를 확인한다 — 일치한다면 코드와 라이브 데이터는 올바르다. 남은 괴리는 훈련 BT와의 사이에 있다.
-
집행 모델 (Execution Model)을 의심한다 — 체결 지연, SL 해상도, 슬리피지, 호가 단위, 수수료. 훈련 BT 측에 이것들을 주입하여 현실에 가깝게 만든다 (구조 유래).
-
그래도 남는 차이는 로그로 설명한다 — 트레이드 하나하나마다 라이브와 훈련 BT에서 "언제·얼마에·왜" 체결되었는지를 나열하고, 차이가 발생하는 지점을 언어화한다.
-
의사결정 순간의 입력을 반드시 로그한다. 결과 (PnL)만 봐서는 괴리를 분리할 수 없다. "그 시각에 모델에 전달한 특징량"이야말로 증거다.
-
타임스탬프 (Timestamp)를 맞춘다. 타임존 (Timezone)이나 "봉의 시각은 봉의 시작인가 종료인가"의 차이는 그 자체로 괴리를 만든다. 선물 등 다른 계열을 특징량에 섞고 있는 경우에는 특히 주의해야 한다 (이 이야기는 다음번 별도로 깊게 다룬다).
-
라이브 전용과 검증 전용으로 코드를 나누지 않는다. 같은 함수를 양쪽 모두 호출하는 구조로 만들어 두면 유사 BT가 자연스럽게 성립한다. 구현이 두 갈래로 나뉘는 순간 괴리의 온상이 된다.
-
괴리를 제로(0)로 만들려고 하지 마라. 목표는 "괴리의 정체를 전부 설명할 수 있는 상태"를 만드는 것이지, 차이를 없애는 것이 아니다.
라이브/BT 괴리는 시스템 트레이딩에서 처음 마주하게 되며, 가장 가치 있는 기술입니다. 요점은 세 가지입니다.
- 괴리에는 버그 유래 (제거 대상)와 구조 유래 (현실에 맞춤 대상)의 두 종류가 있다.
- 유사 BT를 사이에 둔 3자 비교를 통해 어느 쪽인지 판정한다.
- 목표는 차이를 제로로 만드는 것이 아니라, 차이가 발생하는 지점을 전부 설명할 수 있는 것이다.
「왜인지 이길 수 없다」를 「이 부분이 이렇게 어긋나 있기 때문에, 이만큼만큼 훈련 백테스트 (BT)를 비관적으로 만든다」로 변환할 수 있다면, 시스템은 단번에 신뢰할 수 있는 것이 됩니다.
지금까지의 3회에 걸쳐, 검증은 신뢰할 수 있는 것이 되었습니다. 시간도 정보도 누설하지 않고, 라이브와의 차이도 설명할 수 있습니다. 그렇다면——그 올바른 검증 위에서, 무수한 파라미터 (Parameter) 중에서 무엇을 선택하고, 무엇을 믿어야 하는가. 다음 회차에서는 이 시리즈의 정점인 과적합 (Overfitting)과의 싸움에 대해 쓰겠습니다.
질문이나 「우리 환경에서는 이렇게 어긋난다」라는 이야기가 있다면 댓글로 남겨주세요. 연재로서, 검증이 거짓말을 하는 패턴을 순차적으로 격파해 나가겠습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Qiita AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기