Skip to content
Published on

퀀트 팩터 투자 백테스팅: Python으로 모멘텀·밸류 전략 구현 가이드

Authors
  • Name
    Twitter
퀀트 팩터 투자 백테스팅: Python으로 모멘텀·밸류 전략 구현 가이드

왜 개발자가 팩터 투자를 알아야 하는가

"과거 수익률이 미래를 보장하지 않는다"는 말은 맞다. 그런데 그 과거 수익률조차 제대로 측정하지 않고 투자하는 사람이 대부분이다. 퀀트 팩터 투자의 핵심은 감이 아니라 데이터로 의사결정하는 것이고, 백테스팅은 그 데이터 기반 의사결정의 첫 번째 단계다.

개발자에게 이 영역은 특히 유리하다. pandas로 데이터를 다루고, numpy로 통계를 계산하며, Git으로 전략 버전을 관리하는 일은 이미 익숙한 워크플로우다. 문제는 금융 도메인 지식의 부재인데, 이 글에서 그 간극을 메운다.

이 글은 팩터 투자 이론의 학술적 근거부터 Python 코드 구현, 백테스팅 프레임워크 선택, 그리고 실전에서 반드시 마주하는 함정들까지를 다룬다. 단순히 "이렇게 코딩하라"가 아니라, "왜 이렇게 해야 하고, 어디서 실패하는가"를 설명한다.

팩터 투자 이론: Fama-French 모델에서 실전까지

팩터란 무엇인가

팩터(Factor)는 주식 수익률의 변동을 설명하는 체계적 요인이다. 1993년 Fama와 French가 발표한 3-팩터 모델은 시장 리스크(Market), 규모(Size, SMB), 가치(Value, HML) 세 가지 요인으로 주식 수익률의 상당 부분을 설명할 수 있음을 보였다. 이후 2015년 5-팩터 모델에서 수익성(Profitability, RMW)과 투자 성향(Investment, CMA)이 추가되었다.

실무에서 자주 쓰이는 팩터는 크게 6가지다:

  • 밸류(Value): PBR, PER, EV/EBITDA 등이 낮은 저평가 종목에 투자
  • 모멘텀(Momentum): 최근 3~12개월 수익률이 높은 종목이 계속 상승하는 경향
  • 퀄리티(Quality): ROE, 부채비율, 이익 안정성이 우수한 기업
  • 사이즈(Size): 소형주가 대형주보다 장기적으로 높은 수익률을 보이는 경향
  • 저변동성(Low Volatility): 변동성이 낮은 종목이 위험 대비 높은 수익률
  • 배당(Dividend Yield): 높은 배당수익률 종목

한국 시장에서의 팩터 프리미엄

한국 주식시장(KOSPI)에서 팩터 프리미엄에 대한 연구는 해외와 다소 다른 결과를 보인다. 학술 연구에 따르면 한국 시장에서는 사이즈 팩터가 가장 큰 수익률 프리미엄을 보이며, 밸류 팩터도 유의미한 결과를 나타냈다. 반면 모멘텀 팩터는 아시아 시장 특성상 미국 대비 약하거나 통계적으로 비유의미한 경우가 있었다(Chui et al., 2010).

그러나 최근 연구들은 모멘텀과 밸류를 결합한 멀티팩터 전략이 한국 시장에서도 유의미한 초과 수익을 만들 수 있음을 보여준다. 핵심은 단일 팩터에 의존하지 않는 것이다.

모멘텀 vs 밸류: 상관관계와 보완성

모멘텀 전략(과거 승자에 투자)과 밸류 전략(저평가 종목에 투자)은 음의 상관관계를 가지는 경우가 많다. 모멘텀은 인기 종목을 추격 매수하는 반면, 밸류는 시장이 외면한 종목을 매수하기 때문이다. 이 음의 상관관계가 바로 두 전략을 결합할 때의 분산 효과의 원천이다.

구분모멘텀 전략밸류 전략
핵심 지표12개월 수익률 (최근 1개월 제외)PBR, PER, EV/EBITDA
매수 타이밍추세 지속 구간역발상 진입
약점 시장추세 반전기 (모멘텀 크래시)가치 함정 (Value Trap)
한국 시장 유효성단기(13개월) 약함, 중기(612개월) 조건부 유효장기적으로 유의미
상관관계밸류와 음의 상관모멘텀과 음의 상관
최대 단점급락장에서 큰 손실회복까지 오랜 시간 소요

Python 백테스팅 프레임워크 비교

백테스팅 프레임워크 선택은 전략의 복잡도, 데이터 규모, 실행 속도 요구사항에 따라 달라진다. 2025~2026년 기준으로 실질적으로 선택지에 오르는 프레임워크 4가지를 비교한다.

기준BacktraderVectorBTZipline (Reloaded)Backtesting.py
아키텍처이벤트 기반벡터화 (NumPy/Numba)이벤트 기반벡터화
속도보통매우 빠름느림빠름
학습 곡선중간높음높음낮음
라이브 트레이딩지원 (IB 등)PRO 버전 지원미지원미지원
멀티 에셋지원지원주식 중심제한적
커뮤니티활발성장 중레거시소규모
팩터 백테스팅 적합도
최신 버전 (2025)1.9.781.2.0fork 다수0.3.x

추천 조합: 팩터 투자 리서치에는 VectorBT(속도)나 순수 pandas(유연성)로 시작하고, 실제 매매 연동이 필요하면 Backtrader로 전환한다. Zipline은 Quantopian 레거시 코드 참조용으로만 활용한다.

환경 설정 및 데이터 수집

필수 라이브러리 설치

# 기본 데이터 분석
pip install pandas numpy scipy matplotlib seaborn

# 주가 데이터 수집
pip install yfinance pykrx

# 백테스팅 프레임워크
pip install vectorbt backtrader

# 성과 분석
pip install quantstats pyfolio-reloaded

한국 주식 데이터 수집: pykrx 활용

import pandas as pd
import numpy as np
from pykrx import stock
from datetime import datetime, timedelta

def get_kospi_universe(date: str) -> pd.DataFrame:
    """
    특정 날짜 기준 KOSPI 상장 종목의 기본 정보를 수집한다.
    date 형식: 'YYYYMMDD'
    """
    tickers = stock.get_market_ticker_list(date, market="KOSPI")

    records = []
    for ticker in tickers:
        try:
            name = stock.get_market_ticker_name(ticker)
            # 기본 재무 지표 (PER, PBR, 배당수익률)
            fundamental = stock.get_market_fundamental_by_ticker(
                date, market="KOSPI"
            )
            if ticker in fundamental.index:
                row = fundamental.loc[ticker]
                records.append({
                    'ticker': ticker,
                    'name': name,
                    'PER': row.get('PER', np.nan),
                    'PBR': row.get('PBR', np.nan),
                    'DIV': row.get('DIV', np.nan),
                })
        except Exception as e:
            continue

    return pd.DataFrame(records)


def get_price_data(
    tickers: list,
    start: str,
    end: str
) -> pd.DataFrame:
    """
    여러 종목의 종가 데이터를 수집하여 DataFrame으로 반환한다.
    tickers: 종목코드 리스트 (예: ['005930', '000660'])
    start, end: 'YYYYMMDD' 형식
    """
    price_dict = {}
    for ticker in tickers:
        try:
            df = stock.get_market_ohlcv_by_date(start, end, ticker)
            price_dict[ticker] = df['종가']
        except Exception:
            continue

    return pd.DataFrame(price_dict)


# 사용 예시
end_date = '20260301'
start_date = '20240301'  # 2년치 데이터

universe = get_kospi_universe(end_date)
print(f"KOSPI 유니버스: {len(universe)}종목")

# 상위 200종목 시가총액 기준 (유동성 확보)
cap_df = stock.get_market_cap_by_ticker(end_date, market="KOSPI")
top200 = cap_df.nlargest(200, '시가총액').index.tolist()

prices = get_price_data(top200, start_date, end_date)
print(f"수집 완료: {prices.shape}")

해외 주식 데이터: yfinance 활용

import yfinance as yf

def get_sp500_data(period: str = '5y') -> pd.DataFrame:
    """S&P 500 구성 종목의 종가 데이터를 수집한다."""
    # 위키피디아에서 S&P 500 목록 가져오기
    sp500_url = (
        'https://en.wikipedia.org/wiki/'
        'List_of_S%26P_500_companies'
    )
    table = pd.read_html(sp500_url)[0]
    tickers = table['Symbol'].str.replace('.', '-').tolist()

    # yfinance로 일괄 다운로드
    data = yf.download(
        tickers,
        period=period,
        auto_adjust=True,
        threads=True
    )
    return data['Close']


# S&P 500 종가 데이터
sp500_prices = get_sp500_data('5y')
print(f"S&P 500 데이터: {sp500_prices.shape}")

모멘텀 전략 구현

12-1 모멘텀 팩터 계산

학계에서 가장 널리 사용되는 모멘텀 정의는 "12개월 수익률에서 최근 1개월 수익률을 제외한 값"이다. 최근 1개월을 제외하는 이유는 단기 반전(Short-term Reversal) 효과 때문이다. 직전 1개월 급등 종목은 오히려 하락 반전하는 경향이 있다.

def calculate_momentum_factor(
    prices: pd.DataFrame,
    lookback: int = 252,
    skip_recent: int = 21
) -> pd.DataFrame:
    """
    12-1 모멘텀 팩터를 계산한다.

    Parameters
    ----------
    prices : pd.DataFrame
        종가 데이터 (columns: 종목, index: 날짜)
    lookback : int
        모멘텀 계산 기간 (거래일 기준, 기본 252일 = 약 12개월)
    skip_recent : int
        최근 제외 기간 (거래일 기준, 기본 21일 = 약 1개월)

    Returns
    -------
    pd.DataFrame
        각 종목의 모멘텀 스코어
    """
    # 전체 lookback 기간 수익률
    total_return = prices.pct_change(lookback)

    # 최근 skip_recent 기간 수익률
    recent_return = prices.pct_change(skip_recent)

    # 12-1 모멘텀: 전체에서 최근 제외
    # (1 + r_total) / (1 + r_recent) - 1
    momentum = (1 + total_return) / (1 + recent_return) - 1

    return momentum


def create_momentum_portfolio(
    prices: pd.DataFrame,
    rebalance_freq: str = 'M',
    n_quantiles: int = 5,
    long_quantile: int = 5,
    short_quantile: int = 1
) -> pd.DataFrame:
    """
    모멘텀 기반 롱숏 포트폴리오를 구성한다.

    Parameters
    ----------
    prices : pd.DataFrame
        종가 데이터
    rebalance_freq : str
        리밸런싱 빈도 ('M': 월간, 'Q': 분기)
    n_quantiles : int
        분위수 개수 (기본 5 = 퀸타일)
    long_quantile : int
        매수할 분위 (기본 5 = 상위 20%)
    short_quantile : int
        매도할 분위 (기본 1 = 하위 20%)

    Returns
    -------
    pd.DataFrame
        일별 포트폴리오 수익률
    """
    momentum = calculate_momentum_factor(prices)

    # 리밸런싱 날짜 추출
    rebal_dates = prices.resample(rebalance_freq).last().index

    daily_returns = prices.pct_change()
    portfolio_returns = pd.Series(index=prices.index, dtype=float)
    portfolio_returns.iloc[:] = 0.0

    for i in range(len(rebal_dates) - 1):
        date = rebal_dates[i]
        next_date = rebal_dates[i + 1]

        # 해당 날짜의 모멘텀 스코어
        mom_scores = momentum.loc[:date].iloc[-1].dropna()

        if len(mom_scores) < n_quantiles * 2:
            continue

        # 분위수 할당
        quantiles = pd.qcut(
            mom_scores, n_quantiles, labels=False
        ) + 1

        # 롱 포트폴리오 (상위 모멘텀)
        long_stocks = quantiles[quantiles == long_quantile].index
        # 숏 포트폴리오 (하위 모멘텀)
        short_stocks = quantiles[quantiles == short_quantile].index

        # 동일가중 롱숏 수익률
        period_mask = (
            (daily_returns.index > date) &
            (daily_returns.index <= next_date)
        )
        long_ret = daily_returns.loc[
            period_mask, long_stocks
        ].mean(axis=1)
        short_ret = daily_returns.loc[
            period_mask, short_stocks
        ].mean(axis=1)

        portfolio_returns.loc[period_mask] = long_ret - short_ret

    return portfolio_returns.dropna()


# 실행
momentum_returns = create_momentum_portfolio(prices)
cumulative = (1 + momentum_returns).cumprod()
print(f"누적 수익률: {cumulative.iloc[-1]:.2%}")
print(f"연환산 수익률: {momentum_returns.mean() * 252:.2%}")
print(f"연환산 변동성: {momentum_returns.std() * np.sqrt(252):.2%}")
print(f"샤프 비율: {momentum_returns.mean() / momentum_returns.std() * np.sqrt(252):.2f}")

밸류 전략 구현

PBR 기반 밸류 팩터

def calculate_value_factor(
    fundamentals: pd.DataFrame,
    metric: str = 'PBR'
) -> pd.Series:
    """
    밸류 팩터 스코어를 계산한다.
    PBR이 낮을수록 저평가 = 밸류 스코어가 높음.

    Parameters
    ----------
    fundamentals : pd.DataFrame
        종목별 재무 지표 (PBR, PER 등)
    metric : str
        사용할 밸류 지표 (기본 'PBR')

    Returns
    -------
    pd.Series
        밸류 스코어 (높을수록 저평가)
    """
    values = fundamentals[metric].copy()

    # 음수 또는 0 제거 (적자 기업, 자본잠식 등)
    values = values[values > 0]

    # 역수를 취해서 낮은 PBR = 높은 스코어
    value_score = 1.0 / values

    # 극단값 처리: 상하위 1% Winsorize
    lower = value_score.quantile(0.01)
    upper = value_score.quantile(0.99)
    value_score = value_score.clip(lower, upper)

    # Z-score 정규화
    value_score = (
        (value_score - value_score.mean()) / value_score.std()
    )

    return value_score


def backtest_value_strategy(
    prices: pd.DataFrame,
    fundamentals_by_date: dict,
    rebalance_freq: str = 'Q',
    top_pct: float = 0.2
) -> pd.Series:
    """
    밸류 전략 백테스트를 실행한다.

    Parameters
    ----------
    prices : pd.DataFrame
        종가 데이터
    fundamentals_by_date : dict
        날짜별 재무 데이터 딕셔너리
    rebalance_freq : str
        리밸런싱 빈도
    top_pct : float
        상위 비율 (기본 0.2 = 상위 20%)

    Returns
    -------
    pd.Series
        일별 포트폴리오 수익률
    """
    rebal_dates = prices.resample(rebalance_freq).last().index
    daily_returns = prices.pct_change()
    portfolio_returns = pd.Series(
        0.0, index=prices.index
    )

    for i in range(len(rebal_dates) - 1):
        date = rebal_dates[i]
        next_date = rebal_dates[i + 1]

        # 해당 분기의 재무 데이터
        date_key = date.strftime('%Y%m%d')
        if date_key not in fundamentals_by_date:
            continue

        fund = fundamentals_by_date[date_key]
        value_scores = calculate_value_factor(fund, 'PBR')

        # 상위 20% 저평가 종목 선정
        n_stocks = max(1, int(len(value_scores) * top_pct))
        selected = value_scores.nlargest(n_stocks).index

        # 동일가중 포트폴리오 수익률 계산
        valid_stocks = [
            s for s in selected if s in daily_returns.columns
        ]
        period_mask = (
            (daily_returns.index > date) &
            (daily_returns.index <= next_date)
        )
        if valid_stocks:
            port_ret = daily_returns.loc[
                period_mask, valid_stocks
            ].mean(axis=1)
            portfolio_returns.loc[period_mask] = port_ret

    return portfolio_returns.dropna()

멀티팩터 전략: 모멘텀 + 밸류 결합

단일 팩터보다 여러 팩터를 결합하면 분산 효과가 생긴다. 가장 간단한 결합 방식은 Z-score 기반 동일가중 합산이다.

def combine_factors(
    momentum_scores: pd.Series,
    value_scores: pd.Series,
    mom_weight: float = 0.5,
    val_weight: float = 0.5
) -> pd.Series:
    """
    모멘텀과 밸류 팩터를 결합한 복합 스코어를 계산한다.

    Parameters
    ----------
    momentum_scores : pd.Series
        모멘텀 Z-score
    value_scores : pd.Series
        밸류 Z-score
    mom_weight : float
        모멘텀 가중치
    val_weight : float
        밸류 가중치

    Returns
    -------
    pd.Series
        복합 팩터 스코어
    """
    # 공통 종목만 추출
    common = momentum_scores.index.intersection(
        value_scores.index
    )
    mom = momentum_scores.loc[common]
    val = value_scores.loc[common]

    # Z-score 정규화
    mom_z = (mom - mom.mean()) / mom.std()
    val_z = (val - val.mean()) / val.std()

    # 가중 합산
    combined = mom_weight * mom_z + val_weight * val_z

    return combined.sort_values(ascending=False)


def backtest_multifactor(
    prices: pd.DataFrame,
    fundamentals_by_date: dict,
    rebalance_freq: str = 'M',
    n_stocks: int = 30,
    mom_weight: float = 0.5,
    val_weight: float = 0.5
) -> dict:
    """
    멀티팩터 전략의 백테스트를 실행하고 성과 지표를 반환한다.
    """
    rebal_dates = prices.resample(rebalance_freq).last().index
    daily_returns = prices.pct_change()
    portfolio_returns = []

    for i in range(len(rebal_dates) - 1):
        date = rebal_dates[i]
        next_date = rebal_dates[i + 1]
        date_key = date.strftime('%Y%m%d')

        # 모멘텀 스코어 계산
        mom = calculate_momentum_factor(prices)
        mom_scores = mom.loc[:date].iloc[-1].dropna()

        # 밸류 스코어 계산
        if date_key in fundamentals_by_date:
            val_scores = calculate_value_factor(
                fundamentals_by_date[date_key]
            )
        else:
            continue

        # 복합 스코어
        combined = combine_factors(
            mom_scores, val_scores,
            mom_weight, val_weight
        )

        # 상위 n_stocks 종목 선정
        selected = combined.head(n_stocks).index.tolist()
        valid = [
            s for s in selected
            if s in daily_returns.columns
        ]

        period_mask = (
            (daily_returns.index > date) &
            (daily_returns.index <= next_date)
        )
        if valid:
            ret = daily_returns.loc[
                period_mask, valid
            ].mean(axis=1)
            portfolio_returns.append(ret)

    if not portfolio_returns:
        return {}

    result = pd.concat(portfolio_returns)

    # 성과 지표 계산
    annual_return = result.mean() * 252
    annual_vol = result.std() * np.sqrt(252)
    sharpe = annual_return / annual_vol if annual_vol > 0 else 0
    cumulative = (1 + result).cumprod()
    max_dd = (
        cumulative / cumulative.cummax() - 1
    ).min()

    return {
        'annual_return': annual_return,
        'annual_volatility': annual_vol,
        'sharpe_ratio': sharpe,
        'max_drawdown': max_dd,
        'cumulative_return': cumulative.iloc[-1] - 1,
        'daily_returns': result
    }

성과 분석 및 시각화

QuantStats를 활용한 성과 리포트

import quantstats as qs
import matplotlib.pyplot as plt

def generate_performance_report(
    returns: pd.Series,
    benchmark_returns: pd.Series = None,
    strategy_name: str = "Multifactor Strategy"
):
    """
    전략의 성과를 분석하고 시각화한다.

    Parameters
    ----------
    returns : pd.Series
        전략 일별 수익률
    benchmark_returns : pd.Series
        벤치마크 일별 수익률
    strategy_name : str
        전략 이름
    """
    # 기본 통계
    print(f"=== {strategy_name} 성과 분석 ===")
    print(f"연환산 수익률: {qs.stats.cagr(returns):.2%}")
    print(f"연환산 변동성: {qs.stats.volatility(returns):.2%}")
    print(f"샤프 비율: {qs.stats.sharpe(returns):.2f}")
    print(f"소르티노 비율: {qs.stats.sortino(returns):.2f}")
    print(f"최대 낙폭 (MDD): {qs.stats.max_drawdown(returns):.2%}")
    print(f"칼마 비율: {qs.stats.calmar(returns):.2f}")
    print(f"승률: {qs.stats.win_rate(returns):.2%}")

    # HTML 리포트 생성
    qs.reports.html(
        returns,
        benchmark=benchmark_returns,
        title=strategy_name,
        output=f'{strategy_name}_report.html'
    )

    # 핵심 차트
    fig, axes = plt.subplots(2, 2, figsize=(14, 10))

    # 1. 누적 수익률
    cumulative = (1 + returns).cumprod()
    axes[0, 0].plot(cumulative.index, cumulative.values)
    axes[0, 0].set_title('Cumulative Returns')
    axes[0, 0].set_ylabel('Growth of $1')

    # 2. 드로다운
    drawdown = cumulative / cumulative.cummax() - 1
    axes[0, 1].fill_between(
        drawdown.index, drawdown.values, 0,
        alpha=0.5, color='red'
    )
    axes[0, 1].set_title('Drawdown')

    # 3. 월별 수익률 히트맵 데이터
    monthly = returns.resample('M').apply(
        lambda x: (1 + x).prod() - 1
    )
    axes[1, 0].bar(monthly.index, monthly.values)
    axes[1, 0].set_title('Monthly Returns')

    # 4. 롤링 샤프 비율 (12개월)
    rolling_sharpe = (
        returns.rolling(252).mean()
        / returns.rolling(252).std()
        * np.sqrt(252)
    )
    axes[1, 1].plot(rolling_sharpe.index, rolling_sharpe.values)
    axes[1, 1].axhline(y=0, color='r', linestyle='--')
    axes[1, 1].set_title('Rolling 12M Sharpe Ratio')

    plt.tight_layout()
    plt.savefig('strategy_performance.png', dpi=150)
    plt.show()

리스크 관리: 팩터 투자에서 빠지기 쉬운 함정

1. 과적합(Overfitting)

백테스팅에서 가장 치명적인 실수다. 과거 데이터에 맞춰 파라미터를 반복적으로 조정하면, 모델은 과거의 노이즈까지 학습한다. 100가지 전략을 시험하면 통계적으로 5개는 우연히 좋은 결과를 보인다(5% 유의수준 기준).

경고 신호:

  • 백테스트 샤프 비율이 2.0을 크게 초과한다
  • 파라미터를 아주 조금만 바꿔도 결과가 크게 달라진다
  • 특정 시장 국면에서만 성과가 집중된다
  • 시험한 전략 개수를 기록하지 않는다

대응 방법:

  • 데이터를 학습(in-sample)과 검증(out-of-sample)으로 분리한다
  • Walk-forward 분석을 적용한다
  • 시험한 전략 수 대비 Bonferroni 보정을 적용한다
  • 전략의 경제적 직관이 있는지 확인한다

2. 생존 편향(Survivorship Bias)

상장폐지, 합병, 파산된 종목을 데이터에서 제외하면 수익률이 연 1~4%까지 과대 추정된다. "현재 살아 있는 종목"만으로 과거를 테스트하면, 망한 종목에 투자했을 손실이 빠진다.

대응 방법:

  • 상장폐지 종목을 포함한 데이터셋을 사용한다
  • pykrx에서 과거 상장 종목 목록을 날짜별로 조회한다
  • 상장폐지일 기준으로 -100% 또는 실제 최종 거래가를 반영한다

3. 미래 정보 참조(Look-ahead Bias)

재무제표 데이터는 실제 공시일 기준으로 사용해야 한다. 12월 결산 법인의 연간 실적이 실제로 시장에 공개되는 시점은 다음 해 3~4월이다. 1월 시점에서 전년도 연간 PER을 사용하면 아직 공개되지 않은 정보를 쓰는 것이다.

대응 방법:

  • 재무 데이터에 최소 3~6개월 래그(lag)를 적용한다
  • 분기 실적은 공시일(DART 기준)을 확인하여 사용한다
  • Point-in-time 데이터베이스를 구축한다

4. 거래 비용과 슬리피지 무시

실제 매매에서는 수수료, 시장 충격(Market Impact), 매수-매도 호가 차이(Bid-Ask Spread)가 발생한다. 소형주일수록 이 비용이 크다. 한국 주식의 경우 매도 시 증권거래세 0.18%(2026년 기준)도 반영해야 한다.

def apply_transaction_costs(
    returns: pd.Series,
    turnover: pd.Series,
    commission: float = 0.00015,   # 편도 수수료 0.015%
    tax: float = 0.0018,           # 증권거래세 0.18% (매도만)
    slippage: float = 0.001        # 슬리피지 0.1%
) -> pd.Series:
    """
    거래 비용을 반영한 순수익률을 계산한다.

    Parameters
    ----------
    returns : pd.Series
        거래비용 미반영 수익률
    turnover : pd.Series
        일별 회전율 (0~1, 포트폴리오 대비 교체 비율)
    commission : float
        편도 수수료율
    tax : float
        매도 시 세금 (한국: 증권거래세)
    slippage : float
        슬리피지 (시장 충격)

    Returns
    -------
    pd.Series
        비용 반영 순수익률
    """
    # 매수 비용: 수수료 + 슬리피지
    buy_cost = (commission + slippage) * turnover

    # 매도 비용: 수수료 + 세금 + 슬리피지
    sell_cost = (commission + tax + slippage) * turnover

    total_cost = buy_cost + sell_cost

    return returns - total_cost

5. 모멘텀 크래시(Momentum Crash)

모멘텀 전략은 시장이 급락 후 급반등하는 구간에서 치명적인 손실을 입는다. 2009년 3월, 2020년 3월처럼 폭락 직후 반등이 올 때, 이전 패배주(과거 하락 종목)가 급등하면서 모멘텀 롱숏 전략이 큰 손실을 기록한다. 이를 "모멘텀 크래시"라고 부른다.

완화 전략:

  • 시장 변동성이 급등하면 모멘텀 포지션을 축소한다 (VIX 또는 VKOSPI 기반)
  • 모멘텀과 밸류를 결합하여 음의 상관관계를 활용한다
  • 최대 개별 종목 비중을 제한한다 (예: 5% 이하)

VectorBT를 활용한 고속 백테스팅

대량의 파라미터 조합을 빠르게 테스트해야 할 때 VectorBT가 빛을 발한다. NumPy 배열 연산과 Numba JIT 컴파일을 통해 수백만 건의 시뮬레이션을 수십 초 안에 완료할 수 있다.

import vectorbt as vbt

# 모멘텀 시그널 기반 간단한 백테스트 예시
def vectorbt_momentum_backtest(
    prices: pd.DataFrame,
    lookback_windows: list = [63, 126, 189, 252],
    skip_windows: list = [5, 10, 21]
):
    """
    VectorBT를 이용한 모멘텀 전략 파라미터 그리드 서치.
    다양한 lookback/skip 조합을 동시에 테스트한다.
    """
    results = {}

    for lookback in lookback_windows:
        for skip in skip_windows:
            # 모멘텀 계산
            total_ret = prices.pct_change(lookback)
            recent_ret = prices.pct_change(skip)
            momentum = (1 + total_ret) / (1 + recent_ret) - 1

            # 상위 20% 종목만 매수 시그널
            threshold = momentum.quantile(0.8, axis=1)
            entries = momentum.gt(threshold, axis=0)

            # 하위 20%로 내려가면 매도 시그널
            exit_threshold = momentum.quantile(0.5, axis=1)
            exits = momentum.lt(exit_threshold, axis=0)

            # VectorBT 포트폴리오
            pf = vbt.Portfolio.from_signals(
                close=prices,
                entries=entries,
                exits=exits,
                freq='1D',
                init_cash=100_000_000,  # 1억 원
                fees=0.00015,
                slippage=0.001
            )

            results[(lookback, skip)] = {
                'total_return': pf.total_return(),
                'sharpe': pf.sharpe_ratio(),
                'max_dd': pf.max_drawdown(),
                'calmar': pf.calmar_ratio()
            }

    # 결과를 DataFrame으로 정리
    results_df = pd.DataFrame(results).T
    results_df.index.names = ['lookback', 'skip']

    print("=== 파라미터 그리드 서치 결과 ===")
    print(results_df.sort_values(
        'sharpe', ascending=False
    ).head(10))

    return results_df

실전 체크리스트

백테스팅에서 라이브 트레이딩으로 넘어가기 전에 반드시 확인해야 할 항목이다. 하나라도 빠지면 실전에서 예상치 못한 손실을 맞을 수 있다.

데이터 검증 체크리스트

  • 생존 편향이 제거되었는가? 상장폐지 종목이 포함되어 있는가?
  • 주가 데이터에 수정주가(액면분할, 무상증자 반영)가 적용되었는가?
  • 재무 데이터에 적절한 시간 래그가 반영되었는가? (최소 3개월)
  • 결측치(NaN) 처리 방식이 명시되어 있는가?
  • 극단값(Outlier) 처리 기준이 정의되어 있는가?

전략 검증 체크리스트

  • In-sample과 Out-of-sample이 분리되어 있는가?
  • Walk-forward 분석을 수행했는가?
  • 파라미터 민감도 분석을 했는가? (파라미터를 10~20% 변경해도 결과가 유사한가?)
  • 전략에 경제적 직관(Economic Rationale)이 있는가?
  • 최소 2번의 시장 사이클(상승장 + 하락장)을 포함하는가?
  • 거래 비용(수수료 + 세금 + 슬리피지)이 반영되었는가?

리스크 관리 체크리스트

  • 최대 낙폭(MDD)이 허용 범위 내인가?
  • 개별 종목 최대 비중이 제한되어 있는가?
  • 섹터 집중도가 과도하지 않은가?
  • 유동성 필터가 적용되어 있는가? (일평균 거래대금 기준)
  • 레버리지 사용 시 마진콜 시나리오를 테스트했는가?
  • 전략 중단 기준(Stop-loss Rule)이 정의되어 있는가?

운영 체크리스트

  • 데이터 수집 파이프라인이 자동화되어 있는가?
  • 리밸런싱 스케줄러가 설정되어 있는가?
  • 실행 오류 시 알림 시스템이 구축되어 있는가?
  • 전략 코드가 버전 관리(Git)되고 있는가?
  • 백테스트 결과가 재현 가능한가? (시드 고정, 환경 기록)

자주 발생하는 오류와 트러블슈팅

문제 1: 백테스트 수익률이 비현실적으로 높다

원인: 대부분 미래 정보 참조 또는 생존 편향이다. "연 50% 수익률"같은 결과가 나오면 코드에 버그가 있을 확률이 99%다.

진단 방법:

  1. 재무 데이터의 시점을 확인한다 (point-in-time 여부)
  2. 유니버스에 현재 상장 종목만 있는지 확인한다
  3. 거래 비용이 반영되었는지 확인한다
  4. 개별 종목 수익률 기여도를 분해하여 비정상 종목을 찾는다

문제 2: 백테스트와 실전 괴리가 크다

원인: 슬리피지, 유동성, 체결 타이밍의 차이다.

진단 방법:

  1. 슬리피지를 0.1% 단위로 높여가며 성과 변화를 확인한다
  2. 일평균 거래대금 하위 20% 종목을 제외하고 재테스트한다
  3. 종가 기준 시뮬레이션을 VWAP 기준으로 변경해본다

문제 3: 모멘텀 전략이 특정 기간에 급락한다

원인: 모멘텀 크래시 또는 팩터 로테이션 구간이다.

대응:

  1. VIX/VKOSPI가 30을 넘으면 포지션을 50% 축소하는 규칙을 추가한다
  2. 밸류 팩터와 결합하여 위험을 분산한다
  3. 개별 종목 최대 비중을 5% 이하로 제한한다

문제 4: pykrx 데이터 수집이 느리다

원인: 종목별 순차 API 호출이 병목이다.

대응:

  1. 수집 결과를 로컬 SQLite 또는 Parquet 파일로 캐싱한다
  2. concurrent.futures.ThreadPoolExecutor로 병렬 수집한다
  3. 일 단위 증분 업데이트만 수행한다

팩터 전략 비교 요약

평가 항목모멘텀 단독밸류 단독모멘텀+밸류 결합멀티팩터 (5팩터)
기대 연수익률5~15%3~10%7~15%8~18%
연 변동성15~25%10~20%10~18%8~15%
샤프 비율0.3~0.80.3~0.70.5~1.00.6~1.2
최대 낙폭-30~-50%-20~-40%-20~-35%-15~-30%
회전율높음 (월 30~50%)낮음 (분기 10~20%)중간중간
구현 난이도낮음중간중간높음
거래 비용 민감도높음낮음중간중간

위 수치는 학술 연구와 실무 경험을 바탕으로 한 일반적인 범위이며, 특정 시장이나 기간에 따라 크게 달라질 수 있다. 백테스트 결과를 그대로 미래에 적용할 수 없다.

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

백테스팅으로 유망한 전략을 발견했다면, 실전 적용까지는 다음 단계를 거쳐야 한다:

  1. 페이퍼 트레이딩: 최소 3~6개월 간 실제 시장에서 모의 매매를 실행한다. 백테스트와 페이퍼 트레이딩 결과의 차이가 크면 전략을 수정한다.

  2. 소액 실전 투입: 전체 투자금의 10~20%만으로 전략을 실행한다. 이 단계에서 체결 품질, 슬리피지, 운영 이슈를 확인한다.

  3. 점진적 확대: 3개월 이상 실전 결과가 백테스트와 유사하면 비중을 점진적으로 높인다.

  4. 지속적 모니터링: 전략의 팩터 노출, 롤링 샤프 비율, 드로다운을 실시간 모니터링한다. 전략이 "죽었다"는 판단 기준(예: 12개월 롤링 샤프가 0 미만으로 6개월 연속)을 사전에 정의한다.

투자 위험 고지

면책 조항: 이 글에 포함된 모든 코드와 전략은 교육 목적으로만 제공됩니다. 투자 권유가 아니며, 특정 투자 전략이나 종목에 대한 추천을 포함하지 않습니다. 모든 투자에는 원금 손실의 위험이 있으며, 과거 성과(백테스트 결과 포함)는 미래 수익을 보장하지 않습니다. 실제 투자 결정은 본인의 판단과 책임 하에 이루어져야 하며, 필요한 경우 공인 재무 전문가의 조언을 구하시기 바랍니다.

참고 자료

  1. Fama, E. F., & French, K. R. (2015). A five-factor asset pricing model. Journal of Financial Economics.
  2. Kenneth R. French Data Library - Factor Returns Data
  3. Enhanced Factor Investing in the Korean Stock Market (ScienceDirect)
  4. The Factor Mirage: How Quant Models Go Wrong - CFA Institute
  5. Seven Sins of Quantitative Investing - Deutsche Bank Research
  6. VectorBT Documentation - Lightning-fast Backtesting
  7. Backtesting.py - Backtest Trading Strategies in Python
  8. pykrx - 한국 주식 데이터 수집 라이브러리 (GitHub)
  9. 파이썬을 이용한 퀀트 투자 포트폴리오 만들기 (GitHub)
  10. Machine Learning for Factor Investing: Python Version - Oxford Academic (2025)