파이썬 백테스팅 입문

파이썬 백테스팅이란? 왜 직접 검증해야 하는가

“이 전략 수익률 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가지

코드가 돌아간다고 백테스팅이 끝난 게 아닙니다. 다음 함정들을 반드시 인지해야 합니다.

  1. 미래 참조 오류(Look-Ahead Bias): 시그널 계산에 미래 데이터가 섞이는 실수. shift(1)을 빼먹으면 발생합니다.
  2. 과최적화(Overfitting): MA 기간을 20/50에서 17/53으로 바꿨더니 수익률이 올라갔다? 과거에만 맞는 숫자일 가능성이 높습니다.
  3. 거래 비용 무시: 실제 매매에는 수수료(0.04~0.1%)와 슬리피지가 발생합니다. 수수료를 포함하면 수익이 크게 줄어들 수 있습니다.
  4. 생존 편향(Survivorship Bias): 비트코인이나 테슬라처럼 “결과적으로 올라간 종목”만 테스트하면 어떤 전략이든 좋아 보입니다.
  5. 기간 의존성: 상승장에서만 테스트하면 추세추종 전략이 무조건 좋아 보입니다. 반드시 하락장과 횡보장도 포함해야 합니다.

다음 단계: 백테스팅에서 실전으로

이동평균 크로스오버 백테스팅을 완성했다면, 다음 단계로 넘어갈 준비가 된 것입니다.

  • 거래 비용 반영: 매매마다 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) 관련 글

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux