안녕하세요, 흔한트레이더입니다.
백테스트 화면에 누적 수익률 100%가 찍히는 순간, 우리는 드디어 시장을 이기는 알고리즘을 찾았다고 생각합니다. 하지만 실전 계좌에 전략을 올리는 순간부터 이상한 일이 벌어집니다. 매매 방향은 비슷하게 맞았는데 백테스트보다 수익이 계속 작아집니다.
범인은 차트에 표시되지 않는 비용입니다. 매수와 매도 때마다 빠져나가는 수수료와 세금, 내가 원한 가격보다 불리하게 체결되는 슬리피지(Slippage)가 전략의 수익을 조금씩 갉아먹습니다. 한 번에는 먼지처럼 작아 보여도 거래가 반복되면 전략의 생존 여부를 바꾸는 무게가 됩니다.
지난 글에서는 자동매매 백테스트에서 흔히 빠지는 3가지 함정을 살펴봤습니다. 이번에는 이론에서 한 단계 더 나아가, 파이썬 백테스트에 거래비용과 현실적인 체결 시점을 직접 넣어보겠습니다.
이번 글의 핵심 결론
- 비용을 빼지 않은 백테스트는 실전 성과의 상한선에 가깝습니다.
- 당일 종가로 만든 신호는 다음 거래일 시가부터 체결해야 미래 참조를 피할 수 있습니다.
- 거래가 잦은 전략일수록 수수료보다 슬리피지가 더 큰 복병이 될 수 있습니다.
1. 검증 질문: 비용 0원인 시장은 존재하지 않는다
이번 검증에서는 복잡한 전략 대신 누구나 이해할 수 있는 20일·60일 단순 이동평균선(SMA) 교차 전략을 사용합니다. 전략 자체의 우수성을 증명하려는 것이 아니라, 동일한 매매 신호에 비용만 추가했을 때 성과가 얼마나 달라지는지 확인하는 것이 목적입니다.
단순 이동평균선 실전 전략과 마찬가지로 당일 종가에서 신호를 확정하고, 그 신호를 다음 거래일 시가부터 포지션에 반영합니다. 오늘 종가를 보고 오늘 종가에 체결됐다고 가정하는 편리한 오류는 사용하지 않습니다.
| 항목 | 설정값 |
|---|---|
| 대상 종목 | NAVER(A035420) |
| 검증 기간 | 2015-01-02 ~ 2025-12-30 |
| 매수 신호 | SMA 20이 SMA 60보다 높을 때 |
| 매도 신호 | SMA 20이 SMA 60보다 낮을 때 |
| 신호 확정 | 당일 종가 |
| 체결 시점 | 신호 확정 다음 거래일 시가 |
| 가격 데이터 | 수정주가가 반영된 로컬 일봉 데이터 |
결과의 재현 기준은 2026년 8월 17일 로컬 데이터 스냅샷입니다. 데이터 공급원의 수정주가 재처리나 과거 행 정정이 발생하면 같은 기간이라도 결과가 달라질 수 있습니다.
비용은 세 가지 시나리오로 나눴습니다.
| 시나리오 | 매수 비용 | 매도 비용 | 슬리피지 |
|---|---|---|---|
| 비용 미반영 | 0% | 0% | 0% |
| 수수료·세금 | 수수료 0.015% | 수수료 0.015% + 세금 0.15% | 0% |
| 비용+슬리피지 | 위와 동일 | 위와 동일 | 매수·매도 각각 0.05% |
위 수치는 코드의 동작을 보여주기 위한 보수적인 예시입니다. 실제 증권사 수수료, 상품별 세금, 주문 방식에 따른 슬리피지는 서로 다르므로 실전 적용 전 자신의 조건으로 반드시 교체해야 합니다.
2. 수수료보다 무서운 체결 가격의 오차
수수료는 거래명세서에 숫자로 찍히기 때문에 눈에 잘 보입니다. 반면 슬리피지는 별도의 청구서가 없습니다. 내가 10,000원에 살 계획이었지만 실제로는 10,010원에 체결됐다면, 그 10원이 바로 보이지 않는 비용입니다.
이번 예시에서 한 번의 왕복 매매 비용을 단순 합산하면 다음과 같습니다.
매수 수수료 0.015%
+ 매도 수수료 0.015%
+ 매도 세금 0.15%
+ 매수 슬리피지 0.05%
+ 매도 슬리피지 0.05%
= 왕복 약 0.28%
0.28%는 작아 보입니다. 하지만 왕복 거래가 25번 반복되면 단순 합계만 약 7%포인트입니다. 여기에 복리 효과까지 더해지면 최종 자산 곡선의 차이는 더 커집니다.
슬리피지는 모든 종목에서 일정하지도 않습니다. 거래대금이 큰 대형주는 상대적으로 작을 수 있지만, 거래가 뜸한 소형주나 장 시작 직후처럼 호가가 벌어지는 구간에서는 훨씬 커질 수 있습니다. 따라서 고정 슬리피지는 현실을 완벽하게 복제하는 값이 아니라, 낙관적인 백테스트를 보수적으로 깎아보는 첫 번째 안전장치입니다.
3. 미래 참조를 막는 핵심 한 줄, shift(1)
종가를 이용해 이동평균선을 계산했다면 그 신호를 알 수 있는 시점은 장이 끝난 뒤입니다. 그런데 같은 날 종가에 이미 매수한 것으로 계산하면, 아직 확정되지 않은 가격을 미리 알고 거래한 셈이 됩니다.
이를 막는 핵심은 신호를 하루 뒤로 미루는 것입니다.
df["short_ma"] = df["close"].rolling(20).mean()
df["long_ma"] = df["close"].rolling(60).mean()
df["signal"] = (df["short_ma"] > df["long_ma"]).astype(float)
# t일 종가 신호는 t+1일 시가부터 포지션에 반영합니다.
df["position"] = df["signal"].shift(1).fillna(0.0)
# t일 시가에서 t+1일 시가까지의 수익률입니다.
df["open_to_open_return"] = df["open"].shift(-1) / df["open"] - 1.0
단순히 shift(1)을 넣었다는 사실보다 중요한 것은 시간의 순서입니다.
t일 장 마감 → 종가 확정 → 이동평균 신호 계산
→ t+1일 장 시작 → 시가 체결 → t+2일 시가까지 수익 반영
백테스트 코드를 짤 때는 매수 조건보다 먼저 이 시간표를 종이에 적어보는 것이 좋습니다. 데이터의 시점이 한 칸만 어긋나도 평범한 전략이 수익률 수백 퍼센트의 천재 알고리즘으로 둔갑할 수 있습니다.
4. 파이썬으로 거래비용을 차감하는 방법
먼저 포지션이 0에서 1로 바뀌면 진입, 1에서 0으로 바뀌면 청산으로 구분합니다. 매수 비용과 매도 비용을 나누는 이유는 매도 때만 적용되는 비용이 존재할 수 있기 때문입니다.
import pandas as pd
def run_backtest(
price: pd.DataFrame,
short_window: int = 20,
long_window: int = 60,
buy_fee: float = 0.0,
sell_fee: float = 0.0,
sell_tax: float = 0.0,
slippage: float = 0.0,
) -> pd.DataFrame:
result = price.copy()
# 1. 당일 종가로 신호를 확정합니다.
result["short_ma"] = result["close"].rolling(short_window).mean()
result["long_ma"] = result["close"].rolling(long_window).mean()
result["signal"] = (
result["short_ma"] > result["long_ma"]
).astype(float)
# 2. 신호 다음 날 시가부터 포지션을 보유합니다.
result["position"] = result["signal"].shift(1).fillna(0.0)
result["open_to_open_return"] = (
result["open"].shift(-1) / result["open"] - 1.0
)
# 3. 진입과 청산을 분리합니다.
position_change = (
result["position"].diff().fillna(result["position"])
)
result["entry"] = position_change.clip(lower=0.0)
result["exit"] = (-position_change).clip(lower=0.0)
# 4. 매수·매도 비용과 슬리피지를 각각 차감합니다.
result["trade_cost"] = (
result["entry"] * (buy_fee + slippage)
+ result["exit"] * (sell_fee + sell_tax + slippage)
)
gross_return = (
result["position"] * result["open_to_open_return"]
)
result["strategy_return"] = (
gross_return.fillna(0.0) - result["trade_cost"]
)
# 5. 누적 자산과 MDD를 계산합니다.
result["equity"] = (
1.0 + result["strategy_return"]
).cumprod()
result["drawdown"] = (
result["equity"] / result["equity"].cummax() - 1.0
)
return result
세 가지 비용 조건은 같은 함수에 숫자만 다르게 전달합니다.
no_cost = run_backtest(price)
fees_and_tax = run_backtest(
price,
buy_fee=0.00015,
sell_fee=0.00015,
sell_tax=0.0015,
)
all_costs = run_backtest(
price,
buy_fee=0.00015,
sell_fee=0.00015,
sell_tax=0.0015,
slippage=0.0005,
)
독자가 키움증권 주식 DB 만들기에서 구축한 stock.db를 사용한다면 date, open, close 열만 불러와 같은 방식으로 실행할 수 있습니다. 이번 글에 사용한 전체 실행 코드는 별도 파일로 함께 정리했습니다.
5. 검증 결과: 9.17%포인트가 사라졌다
NAVER의 11년 데이터를 동일한 신호로 검증한 결과입니다.
| 시나리오 | 누적 수익률 | CAGR | MDD | 진입 횟수 | 누적 비용 단순합 |
|---|---|---|---|---|---|
| 비용 미반영 | 35.42% | 2.80% | -56.67% | 25회 | 0.00%p |
| 수수료·세금 | 29.45% | 2.38% | -57.59% | 25회 | 4.50%p |
| 비용+슬리피지 | 26.25% | 2.14% | -58.10% | 25회 | 7.00%p |

비용을 전혀 반영하지 않았을 때 누적 수익률은 35.42%였습니다. 그러나 수수료와 세금을 넣자 29.45%로 줄었고, 매수와 매도에 각각 0.05%의 슬리피지까지 추가하자 26.25%까지 내려왔습니다.
표면적으로 사라진 수익은 9.17%포인트입니다. 누적 비용의 단순합은 7%포인트지만, 비용으로 빠져나간 자금이 이후 복리 수익에도 참여하지 못하기 때문에 최종 차이는 더 크게 벌어졌습니다.
MDD도 -56.67%에서 -58.10%로 악화됐습니다. 비용은 수익이 난 거래에만 선택적으로 붙지 않습니다. 손절하는 순간에도 빠져나가고, 가짜 신호에 속아 짧게 사고팔 때도 정확히 차감됩니다.
더 중요한 사실은 이 전략이 겨우 25번 진입했다는 점입니다. 분봉이나 틱 단위로 하루에도 여러 번 매매하는 단타 전략이라면 비용의 누적 속도는 비교할 수 없을 만큼 빨라집니다. 총수익이 아니라 회전율(Turnover)을 함께 봐야 하는 이유입니다.
이번 계산에는 현금 배당, 호가 잔량에 따른 시장충격, 주문 지연, 부분 체결을 별도로 모델링하지 않았습니다. 따라서 이 결과 역시 완성된 실전 체결 시뮬레이션이 아니라, 비용을 전혀 넣지 않은 백테스트에서 한 단계 현실로 이동한 모델로 해석해야 합니다.
6. 거래비용을 넣을 때 흔히 하는 5가지 실수
6.1 왕복 비용을 한 번만 차감한다
매수와 매도는 서로 다른 거래입니다. 수수료와 슬리피지는 양쪽에서 발생하므로 진입과 청산을 분리해야 합니다.
6.2 모든 비용을 매수 시점에 몰아서 뺀다
간단한 근사에서는 가능하지만, 매도에만 적용되는 비용과 보유 중인 미청산 포지션을 정확히 구분하기 어렵습니다. 진입 비용과 청산 비용을 각각의 거래일에 차감하는 편이 투명합니다.
6.3 종가 신호를 같은 날 종가에 체결한다
장 마감 후에야 확정되는 종가를 보고 이미 그 가격에 거래했다고 계산하면 미래 참조 오류가 됩니다. 종가 신호라면 최소한 익일 시가 체결처럼 실제 가능한 가격으로 넘겨야 합니다.
6.4 모든 종목에 같은 슬리피지를 적용한다
유동성, 주문 크기, 호가 스프레드, 주문 시각에 따라 슬리피지는 달라집니다. 고정값 테스트를 통과했다면 다음 단계에서는 거래대금이나 스프레드에 따라 비용이 달라지는 모델로 발전시켜야 합니다.
6.5 현재의 수수료와 세금을 코드에 영구 고정한다
비용 체계는 증권사, 계좌, 상품, 시기에 따라 바뀔 수 있습니다. 숫자를 코드 여러 곳에 흩어놓지 말고 하나의 설정값으로 관리하고, 백테스트 결과에 사용한 가정을 함께 기록해야 재현할 수 있습니다.
7. 실전 투입 전 백테스트 체크리스트
전략의 수익률을 믿기 전에 아래 질문부터 확인해야 합니다.
- 신호를 알 수 있는 시점과 주문 시점이 분리되어 있는가
- 진입과 청산 양쪽의 수수료를 반영했는가
- 매도에 적용되는 비용을 별도로 계산했는가
- 시장가 주문의 슬리피지를 보수적으로 가정했는가
- 거래가 불가능한 정지·상한가·하한가 상황을 처리했는가
- 수정주가와 상장폐지 종목을 점검했는가
- 거래 횟수와 회전율이 비정상적으로 높지 않은가
- 비용을 두 배로 높여도 전략이 살아남는가
마지막 질문이 특히 중요합니다. 비용을 조금 높였다는 이유만으로 수익이 전부 사라진다면, 그 전략의 우위는 시장의 작은 마찰보다도 약하다는 뜻입니다. 실전에서는 백테스트보다 체결 환경이 좋아지기를 기대하기보다, 더 나빠져도 버틸 수 있는지를 먼저 확인해야 합니다.
8. 마치며: 백테스트 수익률에서 먼저 비용부터 빼라
이번 검증을 통해 확인한 결론은 세 가지입니다.
- 비용을 반영하지 않은 수익률은 실전 기대값이 아니라 낙관적인 상한선입니다.
- 종가 신호와 익일 시가 체결을 분리해야 미래 참조 없는 검증이 됩니다.
- 수수료뿐 아니라 슬리피지와 회전율을 함께 봐야 전략의 실제 생존력을 판단할 수 있습니다.
백테스트는 수익을 예쁘게 만드는 도구가 아닙니다. 우리가 생각한 전략을 현실의 거친 조건에 던져보고, 어디서 무너지는지 미리 확인하는 충돌 시험에 가깝습니다.
다음 글에서는 누적 수익률 하나에서 벗어나 MDD, 샤프지수, 승률, 손익비를 한 번에 계산해보겠습니다. 수익률이 더 높은 전략과 실제로 버티기 좋은 전략이 왜 다를 수 있는지 파이썬 코드로 검증할 예정입니다.