수수료를 넣자 전략이 죽었다 — 슬리피지·비용 모델링
2026-04-08 · 3 min read
$ backtest --cost-bps 20
CAGR 30% → 4%
비용 0으로 돌린 백테스트는 백테스트가 아니다
일봉 전략을 돌렸을 때 연 30%가 나왔다. 비용을 넣었더니 연 4%가 됐다. 거래 빈도가 높을수록 이 차이가 커진다. 하루 한 번 리밸런싱하는 전략은 비용이 수익의 대부분을 먹는다.
문제는 이걸 안 넣어도 백테스트가 잘 돌아간다는 것이다. 숫자만 예쁘게 나오고, 실계좌에 붙였을 때 비로소 드러난다.
국내 주식 왕복 비용 구성
| 항목 | 대략 | 언제 |
|---|---|---|
| 위탁수수료 | 증권사·이벤트마다 다름. 0%~0.015% 수준 | 매수·매도 양쪽 |
| 증권거래세 | 매도 시 부과. 세율은 시장·연도별로 다름 | 매도만 |
| 스프레드 | 호가 단위의 절반 정도가 기본 | 체결 시 |
| 시장충격 | 주문 크기 / 유동성에 비례 | 크게 낼수록 |
여기서 두 가지를 강조하고 싶다.
세금은 매도에만 붙는다. 왕복 비용을 "수수료 × 2"로 계산하면 과소평가된다. 그리고 세율·수수료율은 계속 바뀌므로 코드에 상수로 박지 말고 설정으로 뺀다. 내 백테스트 설정 파일에는 이렇게 들어 있다.
costs:
commission_bps: 1.5 # 왕복 각각
tax_sell_bps: 20 # 매도만. 현재 기준값을 확인해서 넣을 것
half_spread_bps: 5숫자 자체보다 어떤 값을 썼는지 결과에 같이 기록하는 게 중요하다. 비용 가정이 다른 두 백테스트를 비교하면 아무 의미가 없다.
스프레드: 체결가를 불리하게 민다
중간가에 체결됐다고 가정하면 안 된다. 사면 매도호가에, 팔면 매수호가에 닿는다.
def fill_price(mid: float, side: str, half_spread_bps: float) -> float:
sign = 1 if side == "buy" else -1
return mid * (1 + sign * half_spread_bps / 10_000)국내 주식은 가격대별로 호가 단위가 다르다. 저가주는 1원 단위라 1,000원짜리 종목의 한 틱이 10bp다. 스프레드를 고정 bp로 두면 저가주에서 과소평가된다. 가격대별 호가 단위를 반영하는 게 더 정확하다.
시장충격: 크게 사면 더 비싸게 산다
내 주문이 호가를 밀어 올린다. 소액이면 무시해도 되는데, 유동성 낮은 종목에서는 이게 제일 큰 비용이다.
간단한 근사로 제곱근 모델을 쓴다.
def impact_bps(order_value: float, adv: float, k: float = 10) -> float:
"""adv = 최근 평균 거래대금"""
return k * math.sqrt(order_value / adv)k는 추정치라 정확하지 않다. 그래서 나는 이걸 정밀한 예측이 아니라
필터로 쓴다. 하루 거래대금의 일정 비율을 넘는 주문은 아예 못 내게 막는다.
MAX_PARTICIPATION = 0.01 # ponytail: 거래대금의 1%까지만. 실측 후 조정
if order_value > adv * MAX_PARTICIPATION:
order_value = adv * MAX_PARTICIPATION이 제한이 없으면 백테스트가 "일 거래대금 3천만원짜리 종목을 1억어치 매수" 같은 불가능한 거래로 수익을 만든다. 유동성 필터를 안 걸면 백테스트 수익의 상당 부분이 살 수 없는 종목에서 나온다.
체결 자체를 못 할 수도 있다
지정가 주문은 가격이 안 오면 체결이 안 된다. 백테스트에서 전량 체결을 가정하면 실전보다 유리해진다. 최소한 두 케이스는 처리해야 한다.
- 상한가/하한가: 그날 거래 자체가 안 되거나 한 방향만 체결
- 거래정지: 봉이 없다. 청산 신호가 나와도 못 판다
if bar.halted or bar.close >= bar.upper_limit:
return Fill(qty=0, reason="unfillable")"못 판 채로 다음 날로 넘어가는" 경로가 백테스트에 없으면, 리스크가 실제보다 작게 측정된다.
민감도로 확인한다
정확한 비용을 맞추려 애쓰는 것보다, 비용에 얼마나 민감한지를 보는 게 낫다.
for bps in [0, 10, 20, 30, 50]:
r = backtest(strategy, cost_bps=bps)
print(f"{bps:>3}bp → CAGR {r.cagr:6.1%} MDD {r.mdd:6.1%} 거래 {r.trades}")비용 10bp만 올려도 수익이 음수가 되는 전략은, 비용 추정이 조금만 틀려도 실전에서 손실이다. 비용 가정에 둔감한 전략만 살린다. 이 표 하나가 "이 전략을 실계좌에 붙일지" 판단을 거의 결정해준다.
검증
def test_cost_always_hurts():
"""비용은 어떤 방향이든 수익을 깎아야 한다"""
assert backtest(s, cost_bps=20).cagr < backtest(s, cost_bps=0).cagr
def test_sell_taxed_buy_not():
assert cost_of(side="sell", value=1_000_000) > cost_of(side="buy", value=1_000_000)첫 테스트가 실패한다면 비용이 어딘가에서 부호가 뒤집혀 수익으로 잡히고 있는 것이다. 웃긴 버그 같지만 실제로 났었다.
한 줄 요약
수수료·매도세·스프레드·충격을 다 넣고, 거래대금 참여율을 제한하고, 비용 민감도 표로 전략을 거른다.