
백테스트에 「마찰」을 구현하기 —— 체결 모델을 만드는 방법
요약
백테스트와 실제 거래 사이의 괴리를 줄이기 위해 체결 지연, 슬리피지, 시간 해상도와 같은 '마찰(Friction)' 요소를 모델링하는 방법을 다룹니다. 이상적인 백테스트의 가정을 현실적인 데이터 기반 모델로 전환하는 구체적인 구현 가이드를 제공합니다.
핵심 포인트
- 체결 지연(Latency)을 고정값 또는 확률 분포로 모델링하여 구현
- 슬리피지(Slippage)를 시그널 가격과 실제 체결가 차이로 반영
- 봉(Bar) 단위가 아닌 틱(Tick) 단위의 시간 해상도 고려 필요
- 마찰 비용을 별도로 기록하여 전략 수익률과 분리 측정
이 기사는 Zenn에 게시했던 내용을 재게재한 것입니다. 초출: 백테스트에 「마찰」을 구현하기 —— 체결 모델을 만드는 방법
이전에 「검증이 거짓말을 할 때」라는 연재에서, 라이브(Live)와 백테스트(Backtest)의 괴리에 대해 썼습니다. 그중에서, 버그가 아닌 구조적인 괴리——체결 지연(Execution Delay)이나 SL(Stop Loss)의 시간 해상도(Time Resolution) 이야기를 하며, "대처법은 훈련 BT(Backtest) 측을 현실에 가깝게 만드는 것이다"라고 끝맺었습니다.
이번에는 그 숙제의 구현 편입니다. 백테스트가 이상화하고 있는 부분을 어떻게 「마찰 (Friction)」으로서 모델화할 것인지, 그리고 어디까지 해야 하는지를 구체적으로 작성하겠습니다.
소박한 백테스트는 암묵적으로 다음과 같이 가정합니다.
- 지정가/시장가 주문을 내면, 해당 봉(Bar)의 가격으로 정확히 체결된다.
- 주문을 낸 후 체결될 때까지의 **시간은 제로(0)**이다.
- 자신의 주문은 시장에 아무런 영향도 주지 않는다.
현실에서는 어느 것도 성립하지 않습니다. 이 세 가지 갭을 메우는 것이 이번에 만들 체결 모델입니다.
시그널(Signal)이 나오고 실제로 주문이 호가창(Order Book)에 전달되어 체결되기까지는 시간이 걸립니다. 네트워크, 증권사 시스템, 호가창 처리——이것들을 쌓아 올리면 무시할 수 없는 시간이 됩니다.
from dataclasses import dataclass
import numpy as np
@dataclass
...
latency_ms를 고정값으로 시작하는 것이 현실적입니다. 분포(정규 분포나 로그 정규 분포)로 샘플링하여 폭을 갖게 하는 고도화는, 우선 고정값으로 전체적인 효과를 확인한 뒤에 해도 충분합니다.
체결 가격은 시그널 발생 시의 가격에서 반드시 어긋납니다. 방향은 기본적으로 불리한 쪽(매수라면 높게, 매도라면 낮게)으로 편향됩니다.
def apply_slippage(self, base_price, side: str):
slip = self.slippage_ticks * self.tick_size
if side == "buy":
...
슬리피지(Slippage) 폭의 추정은 가능하다면 라이브의 실제 체결 이력으로부터 역산하는 것이 가장 정확합니다. 「시그널 가격과 실제 체결 가격의 차이」를 집계하여 그 중앙값이나 분포를 사용합니다. 아직 라이브 이력이 없다면, 호가 단위(Tick size)의 1~3 틱 정도를 보수적인 초기값으로 설정하고, 나중에 실제 데이터에 맞춰 업데이트합니다.
지난번에 언급했듯이, 소박한 구현은 「봉의 저가가 SL 가격을 돌파하면, 해당 봉에서 체결된다」라고 가정하기 쉽습니다. 이는 봉 내부의 어느 시점에서 돌파했는지를 무시하고 있습니다.
def resolve_stop(self, bar, sl_price, tick_stream=None):
"""
tick_stream 이 있으면, 틱 단위로 SL 터치 실시간을 특정한다.
...
이상적인 것은 틱 데이터(Tick Data)를 통한 재현이지만, 없다면 「봉의 후반부에서 터치했다」라고 가정하는 보수적인 근사치라도, 아무것도 하지 않는 것보다는 훨씬 현실에 가까워집니다.
3요소를 하나의 흐름으로 통합합니다.
class FrictionModel:
def __init__(self, latency_ms=300.0, slippage_ticks=1.0, tick_size=1.0):
self.exec_model = ExecutionModel(latency_ms, slippage_ticks, tick_size)
...
cost 열을 기록해 두면, 나중에 「마찰만으로 얼마나 깎여 나갔는지」를 전략과 분리하여 측정할 수 있습니다. 이것이 다음 검증 단계의 재료가 됩니다.
가공의 숫자를 막연하게 두는 것은 의미가 없습니다. 우선순위는 다음과 같습니다.
- 라이브 실제 체결 이력이 있는 경우: 시그널 시각·시그널 가격·실제 체결 시각·실제 체결 가격을 로그로 남겨두고, 거기서
latency_ms와slippage_ticks의 실측 분포를 만든다. 이것이 가장 신뢰할 수 있다. - 라이브 이력이 아직 없는 경우: 증권사 API 사양(주문~체결의 예상 레이턴시)과 대상 종목의 호가 단위·거래량을 바탕으로 보수적인 초기값을 설정한다. 일부러 엄격하게 추정해 두면, 나중에 "생각보다 좋았다"는 방향으로만 빗나간다.
- 감도 분석 (Sensitivity Analysis):
latency_ms나slippage_ticks를 의도적으로 ±50% 변화시켜 전략의 성적이 얼마나 흔들리는지 확인한다. 마찰에 대한 감도가 높은 전략은 그만큼 실전에서도 취약하다는 뜻이다.
마찰 모델을 도입하기 전후로 지표를 나란히 놓고 비교합니다.
def compare_with_without_friction(trades_ideal, trades_friction):
return pd.DataFrame({
"이상적 (마찰 없음)": summarize(trades_ideal),
...
여기서 주목해야 할 점은 "성적이 떨어졌는가"가 아니라, 어떻게 떨어졌는지를 설명할 수 있는가입니다. 하락 폭이 cost 열의 합계와 대략 일치한다면, 마찰 모델(friction model)은 올바르게 작동하고 있는 것입니다. 반대로 설명할 수 없는 하락 폭이 남아 있다면, 아직 포착하지 못한 구조적인 괴리가 다른 곳에 있다는 신호입니다. 이는 지난 연재에서 다루었던 "의사 백테스트(pseudo-BT)에서의 3자 비교"와 맥을 같이 하는 발상입니다.
- 이상화된 백테스트는 지연(latency)・슬리피지(slippage)・손절(SL) 해상도의 세 가지 요소를 암묵적으로 무시하고 있습니다. 각 요소는 고정값을 사용하는 간단한 모델부터 시작하면 충분합니다. 분포화(distribution)는 나중에 해도 됩니다.
- 파라미터는 막연하게 정하는 것이 아니라, 라이브 실체결 데이터로부터의 역산 → 보수적인 초기값 설정 → 감도 분석(sensitivity analysis) 순으로 결정합니다.
- 마찰을 도입한 후에는 성적의 하락 폭을
cost로 설명할 수 있는지 확인합니다. 설명할 수 없는 하락 폭은 아직 발견하지 못한 괴리의 신호입니다.
백테스트에 마찰을 추가하는 작업은 지루하며, 성적은 반드시 떨어집니다. 하지만 그것은 손실이 아니라, 지금까지 보이지 않았던 진짜 기대값이 보이게 된 것뿐입니다.
질문이나 "우리는 이렇게 추정했다"라는 이야기가 있다면 댓글로 남겨주세요.
AI 자동 생성 콘텐츠
본 콘텐츠는 Qiita AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기