파이썬 백테스팅이란? 왜 직접 검증해야 하는가
“이 전략 수익률 300%!” 같은 말을 들으면 솔깃하지만, 직접 과거 데이터로 검증(백테스팅)하지 않으면 그 숫자는 아무 의미가 없습니다. 백테스팅은 과거 가격 데이터에 매매 규칙을 적용해 가상으로 거래한 결과를 확인하는 과정입니다. 퀀트 투자와 자동매매의 첫 번째 단계이자, 가장 중요한 단계이기도 합니다.
이 글에서는 파이썬으로 가장 대표적인 전략인 이동평균 크로스오버(Golden Cross / Death Cross)를 직접 백테스팅하는 전체 과정을 다룹니다. 코드를 따라 하면 누구나 자신만의 백테스팅 시스템을 만들 수 있습니다.
이동평균 크로스오버 전략이란?
이동평균 크로스오버는 단기 이동평균선이 장기 이동평균선을 상향 돌파하면 매수, 하향 돌파하면 매도하는 전략입니다.
- 골든 크로스(Golden Cross): 단기 MA가 장기 MA를 위로 교차 → 매수 시그널
- 데드 크로스(Death Cross): 단기 MA가 장기 MA를 아래로 교차 → 매도 시그널
가장 흔한 조합은 50일 이동평균과 200일 이동평균이지만, 단기 트레이딩에서는 5일/20일, 10일/30일 조합도 많이 사용됩니다. 중요한 건 “어떤 조합이 내 종목에 맞는지”를 백테스팅으로 확인하는 것입니다.
백테스팅 환경 준비: 필요한 라이브러리
파이썬 백테스팅에 필요한 핵심 라이브러리는 다음과 같습니다.
pip install pandas numpy yfinance matplotlib
| 라이브러리 | 역할 |
|---|---|
pandas |
시계열 데이터 처리, DataFrame 기반 분석 |
numpy |
수학 연산, 수익률 계산 |
yfinance |
Yahoo Finance에서 무료 가격 데이터 다운로드 |
matplotlib |
결과 시각화(차트) |
Step 1: 가격 데이터 가져오기
먼저 yfinance로 원하는 종목의 과거 가격 데이터를 가져옵니다. 비트코인(BTC-USD)을 예시로 사용하겠습니다.
import yfinance as yf
import pandas as pd
import numpy as np
# 비트코인 3년치 일봉 데이터
df = yf.download('BTC-USD', start='2023-01-01', end='2026-01-01')
df = df[['Close']].copy()
df.columns = ['close']
print(f"데이터 기간: {df.index[0].date()} ~ {df.index[-1].date()}")
print(f"총 {len(df)}개 캔들")
핵심 포인트: 백테스팅 데이터는 최소 1년 이상 확보해야 의미 있는 결과를 얻을 수 있습니다. 데이터가 짧으면 전략의 성능을 과대평가하기 쉽습니다.
Step 2: 이동평균 계산과 시그널 생성
# 이동평균 계산
short_window = 20 # 단기: 20일
long_window = 50 # 장기: 50일
df['ma_short'] = df['close'].rolling(window=short_window).mean()
df['ma_long'] = df['close'].rolling(window=long_window).mean()
# 시그널 생성: 단기 MA > 장기 MA이면 1(매수), 아니면 0
df['signal'] = 0
df.loc[df['ma_short'] > df['ma_long'], 'signal'] = 1
# 실제 포지션 변화 (진입/청산 시점)
df['position'] = df['signal'].diff()
# position == 1 → 매수 진입
# position == -1 → 매도 청산
buy_signals = df[df['position'] == 1]
sell_signals = df[df['position'] == -1]
print(f"매수 시그널: {len(buy_signals)}회")
print(f"매도 시그널: {len(sell_signals)}회")
diff()를 사용하는 이유는 “교차 시점”만 포착하기 위해서입니다. 단순히 MA 위/아래 여부만 보면 매일 매수 신호가 발생하지만, diff로 변화가 일어난 순간만 잡아냅니다.
Step 3: 수익률 계산
# 일별 수익률
df['daily_return'] = df['close'].pct_change()
# 전략 수익률: 포지션이 있을 때만 수익률 반영
df['strategy_return'] = df['signal'].shift(1) * df['daily_return']
# 누적 수익률
df['cumulative_market'] = (1 + df['daily_return']).cumprod()
df['cumulative_strategy'] = (1 + df['strategy_return']).cumprod()
# 최종 성과
market_return = (df['cumulative_market'].iloc[-1] - 1) * 100
strategy_return = (df['cumulative_strategy'].iloc[-1] - 1) * 100
print(f"시장 수익률(Buy & Hold): {market_return:.1f}%")
print(f"전략 수익률(MA Cross): {strategy_return:.1f}%")
중요: shift(1)을 반드시 적용해야 합니다. 시그널은 오늘 종가로 계산되므로, 실제 매매는 다음 날부터 반영됩니다. 이걸 빼먹으면 미래 정보를 사용하는 오류(look-ahead bias)가 발생합니다.
Step 4: 핵심 성과 지표 계산
수익률만으로는 전략을 평가할 수 없습니다. 반드시 확인해야 할 지표들이 있습니다.
# 1. 샤프 비율 (Sharpe Ratio)
sharpe = df['strategy_return'].mean() / df['strategy_return'].std() * np.sqrt(252)
print(f"샤프 비율: {sharpe:.2f}")
# 2. 최대 낙폭 (Maximum Drawdown)
cummax = df['cumulative_strategy'].cummax()
drawdown = (df['cumulative_strategy'] - cummax) / cummax
max_drawdown = drawdown.min() * 100
print(f"최대 낙폭(MDD): {max_drawdown:.1f}%")
# 3. 승률
trades = []
entry_price = None
for i, row in df.iterrows():
if row['position'] == 1:
entry_price = row['close']
elif row['position'] == -1 and entry_price is not None:
pnl = (row['close'] - entry_price) / entry_price
trades.append(pnl)
entry_price = None
wins = [t for t in trades if t > 0]
win_rate = len(wins) / len(trades) * 100 if trades else 0
avg_win = np.mean(wins) * 100 if wins else 0
losses = [t for t in trades if t <= 0]
avg_loss = np.mean(losses) * 100 if losses else 0
print(f"총 거래: {len(trades)}회 | 승률: {win_rate:.0f}%")
print(f"평균 수익: +{avg_win:.1f}% | 평균 손실: {avg_loss:.1f}%")
| 지표 | 의미 | 기준 |
|---|---|---|
| 샤프 비율 | 위험 대비 수익 | 1.0 이상이면 양호 |
| 최대 낙폭(MDD) | 최고점 대비 최대 하락 | -20% 이내 권장 |
| 승률 | 수익 거래 비율 | 40% 이상 + 손익비 확인 |
Step 5: 결과 시각화
import matplotlib.pyplot as plt
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(14, 8), sharex=True)
# 상단: 가격 + 이동평균 + 매매 시그널
ax1.plot(df.index, df['close'], label='BTC 종가', alpha=0.7)
ax1.plot(df.index, df['ma_short'], label=f'MA{short_window}', linewidth=1)
ax1.plot(df.index, df['ma_long'], label=f'MA{long_window}', linewidth=1)
ax1.scatter(buy_signals.index, buy_signals['close'], marker='^',
color='green', s=100, label='매수')
ax1.scatter(sell_signals.index, sell_signals['close'], marker='v',
color='red', s=100, label='매도')
ax1.legend()
ax1.set_title('이동평균 크로스오버 백테스팅')
# 하단: 누적 수익률 비교
ax2.plot(df.index, df['cumulative_market'], label='Buy & Hold')
ax2.plot(df.index, df['cumulative_strategy'], label='MA Cross 전략')
ax2.legend()
ax2.set_title('누적 수익률 비교')
plt.tight_layout()
plt.savefig('backtest_result.png', dpi=150)
plt.show()
백테스팅의 흔한 함정 5가지
코드가 돌아간다고 백테스팅이 끝난 게 아닙니다. 다음 함정들을 반드시 인지해야 합니다.
- 미래 참조 오류(Look-Ahead Bias): 시그널 계산에 미래 데이터가 섞이는 실수.
shift(1)을 빼먹으면 발생합니다. - 과최적화(Overfitting): MA 기간을 20/50에서 17/53으로 바꿨더니 수익률이 올라갔다? 과거에만 맞는 숫자일 가능성이 높습니다.
- 거래 비용 무시: 실제 매매에는 수수료(0.04~0.1%)와 슬리피지가 발생합니다. 수수료를 포함하면 수익이 크게 줄어들 수 있습니다.
- 생존 편향(Survivorship Bias): 비트코인이나 테슬라처럼 “결과적으로 올라간 종목”만 테스트하면 어떤 전략이든 좋아 보입니다.
- 기간 의존성: 상승장에서만 테스트하면 추세추종 전략이 무조건 좋아 보입니다. 반드시 하락장과 횡보장도 포함해야 합니다.
다음 단계: 백테스팅에서 실전으로
이동평균 크로스오버 백테스팅을 완성했다면, 다음 단계로 넘어갈 준비가 된 것입니다.
- 거래 비용 반영: 매매마다 0.05~0.1% 수수료를 차감하는 로직 추가
- 다른 전략 테스트: RSI, 볼린저 밴드, MACD 등 다른 지표와 조합
- 워크포워드 분석: 데이터를 학습 구간과 검증 구간으로 나눠 과최적화 방지
- 자동매매 연동: 검증된 전략을 거래소 API와 연결해 실시간 실행
백테스팅은 “이 전략이 돈을 벌 수 있는가”를 확인하는 게 아니라, “이 전략이 왜 돈을 벌거나 잃는지 이해”하는 과정입니다. 숫자에 속지 말고, 로직을 이해하세요.
관련 글: 수익보다 먼저 지켜야 할 계좌 생존 규칙 7가지도 함께 읽어보세요. 또한 자동매매 서버 운영에 필요한 Kubernetes 리소스 관리도 참고하면 좋습니다.
7) 슬리피지와 수수료를 반영한 현실적 백테스트
이동평균 크로스오버 전략의 가장 큰 함정은 거래 비용을 무시한 백테스트입니다. 수수료와 슬리피지를 추가하면 결과가 크게 달라집니다.
import pandas as pd
import numpy as np
def realistic_backtest(
df: pd.DataFrame,
short_window: int = 20,
long_window: int = 60,
commission_pct: float = 0.04, # 바이낸스 taker 0.04%
slippage_pct: float = 0.05, # 슬리피지 0.05%
initial_capital: float = 10_000_000,
) -> dict:
"""수수료 + 슬리피지 반영 백테스트"""
df = df.copy()
df['sma_short'] = df['close'].rolling(short_window).mean()
df['sma_long'] = df['close'].rolling(long_window).mean()
# 시그널: 1=롱, -1=숏, 0=관망
df['signal'] = 0
df.loc[df['sma_short'] > df['sma_long'], 'signal'] = 1
df.loc[df['sma_short'] <= df['sma_long'], 'signal'] = -1
df['position'] = df['signal'].shift(1)
df['trade'] = df['position'].diff().abs() # 포지션 변경 = 거래
# 수익률 계산
df['returns'] = df['close'].pct_change()
df['strategy_gross'] = df['position'] * df['returns']
# 거래 비용 차감
total_cost_pct = (commission_pct + slippage_pct) / 100
df['costs'] = df['trade'] * total_cost_pct
df['strategy_net'] = df['strategy_gross'] - df['costs']
# 누적 수익률
df['equity_gross'] = initial_capital * (1 + df['strategy_gross']).cumprod()
df['equity_net'] = initial_capital * (1 + df['strategy_net']).cumprod()
n_trades = int(df['trade'].sum())
total_cost = (df['equity_gross'].iloc[-1] - df['equity_net'].iloc[-1])
print(f"거래 횟수: {n_trades}회")
print(f"총 비용: {total_cost:,.0f}원")
print(f"수익률 (비용 전): {(df['equity_gross'].iloc[-1]/initial_capital - 1):.2%}")
print(f"수익률 (비용 후): {(df['equity_net'].iloc[-1]/initial_capital - 1):.2%}")
return df
8) 이동평균 파라미터 최적화 (그리드 서치)
def optimize_ma_params(df: pd.DataFrame,
short_range=range(5, 50, 5),
long_range=range(20, 200, 10)):
"""이동평균 파라미터 그리드 서치"""
results = []
for short in short_range:
for long_ in long_range:
if short >= long_:
continue
sma_s = df['close'].rolling(short).mean()
sma_l = df['close'].rolling(long_).mean()
signal = pd.Series(0, index=df.index)
signal[sma_s > sma_l] = 1
signal[sma_s 0 else 0
total_ret = (1 + ret).prod() - 1
n_trades = position.diff().abs().sum()
results.append({
'short': short, 'long': long_,
'sharpe': round(sharpe, 2),
'return': round(total_ret * 100, 1),
'trades': int(n_trades)
})
results_df = pd.DataFrame(results).sort_values('sharpe', ascending=False)
# ⚠️ 최적 파라미터 주변이 안정적인지 확인 (로버스트성)
print("Top 5 파라미터 조합:")
print(results_df.head())
return results_df
9) 관련 글
- 이동평균 크로스오버 전략 심화 — SMA, EMA, DEMA의 차이와 최적 활용법을 다룹니다.
- 백테스트 오버피팅 방지법 — 그리드 서치 후 오버피팅 여부를 검증하는 방법입니다.
- 슬리피지 최적화 전략 — 백테스트에 반영한 슬리피지를 실전에서 줄이는 방법입니다.
- 샤프 비율 성과 분석 — 백테스트 결과를 정량적으로 평가하는 지표입니다.