- Authors
- Name
- 왜 개발자가 팩터 투자를 알아야 하는가
- 팩터 투자 이론: Fama-French 모델에서 실전까지
- Python 백테스팅 프레임워크 비교
- 환경 설정 및 데이터 수집
- 모멘텀 전략 구현
- 밸류 전략 구현
- 멀티팩터 전략: 모멘텀 + 밸류 결합
- 성과 분석 및 시각화
- 리스크 관리: 팩터 투자에서 빠지기 쉬운 함정
- VectorBT를 활용한 고속 백테스팅
- 실전 체크리스트
- 자주 발생하는 오류와 트러블슈팅
- 팩터 전략 비교 요약
- 다음 단계: 백테스팅에서 실전으로
- 투자 위험 고지
- 참고 자료

왜 개발자가 팩터 투자를 알아야 하는가
"과거 수익률이 미래를 보장하지 않는다"는 말은 맞다. 그런데 그 과거 수익률조차 제대로 측정하지 않고 투자하는 사람이 대부분이다. 퀀트 팩터 투자의 핵심은 감이 아니라 데이터로 의사결정하는 것이고, 백테스팅은 그 데이터 기반 의사결정의 첫 번째 단계다.
개발자에게 이 영역은 특히 유리하다. 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) |
| 한국 시장 유효성 | 단기(1 | 장기적으로 유의미 |
| 상관관계 | 밸류와 음의 상관 | 모멘텀과 음의 상관 |
| 최대 단점 | 급락장에서 큰 손실 | 회복까지 오랜 시간 소요 |
Python 백테스팅 프레임워크 비교
백테스팅 프레임워크 선택은 전략의 복잡도, 데이터 규모, 실행 속도 요구사항에 따라 달라진다. 2025~2026년 기준으로 실질적으로 선택지에 오르는 프레임워크 4가지를 비교한다.
| 기준 | Backtrader | VectorBT | Zipline (Reloaded) | Backtesting.py |
|---|---|---|---|---|
| 아키텍처 | 이벤트 기반 | 벡터화 (NumPy/Numba) | 이벤트 기반 | 벡터화 |
| 속도 | 보통 | 매우 빠름 | 느림 | 빠름 |
| 학습 곡선 | 중간 | 높음 | 높음 | 낮음 |
| 라이브 트레이딩 | 지원 (IB 등) | PRO 버전 지원 | 미지원 | 미지원 |
| 멀티 에셋 | 지원 | 지원 | 주식 중심 | 제한적 |
| 커뮤니티 | 활발 | 성장 중 | 레거시 | 소규모 |
| 팩터 백테스팅 적합도 | 중 | 상 | 상 | 하 |
| 최신 버전 (2025) | 1.9.78 | 1.2.0 | fork 다수 | 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%다.
진단 방법:
- 재무 데이터의 시점을 확인한다 (point-in-time 여부)
- 유니버스에 현재 상장 종목만 있는지 확인한다
- 거래 비용이 반영되었는지 확인한다
- 개별 종목 수익률 기여도를 분해하여 비정상 종목을 찾는다
문제 2: 백테스트와 실전 괴리가 크다
원인: 슬리피지, 유동성, 체결 타이밍의 차이다.
진단 방법:
- 슬리피지를 0.1% 단위로 높여가며 성과 변화를 확인한다
- 일평균 거래대금 하위 20% 종목을 제외하고 재테스트한다
- 종가 기준 시뮬레이션을 VWAP 기준으로 변경해본다
문제 3: 모멘텀 전략이 특정 기간에 급락한다
원인: 모멘텀 크래시 또는 팩터 로테이션 구간이다.
대응:
- VIX/VKOSPI가 30을 넘으면 포지션을 50% 축소하는 규칙을 추가한다
- 밸류 팩터와 결합하여 위험을 분산한다
- 개별 종목 최대 비중을 5% 이하로 제한한다
문제 4: pykrx 데이터 수집이 느리다
원인: 종목별 순차 API 호출이 병목이다.
대응:
- 수집 결과를 로컬 SQLite 또는 Parquet 파일로 캐싱한다
concurrent.futures.ThreadPoolExecutor로 병렬 수집한다- 일 단위 증분 업데이트만 수행한다
팩터 전략 비교 요약
| 평가 항목 | 모멘텀 단독 | 밸류 단독 | 모멘텀+밸류 결합 | 멀티팩터 (5팩터) |
|---|---|---|---|---|
| 기대 연수익률 | 5~15% | 3~10% | 7~15% | 8~18% |
| 연 변동성 | 15~25% | 10~20% | 10~18% | 8~15% |
| 샤프 비율 | 0.3~0.8 | 0.3~0.7 | 0.5~1.0 | 0.6~1.2 |
| 최대 낙폭 | -30~-50% | -20~-40% | -20~-35% | -15~-30% |
| 회전율 | 높음 (월 30~50%) | 낮음 (분기 10~20%) | 중간 | 중간 |
| 구현 난이도 | 낮음 | 중간 | 중간 | 높음 |
| 거래 비용 민감도 | 높음 | 낮음 | 중간 | 중간 |
위 수치는 학술 연구와 실무 경험을 바탕으로 한 일반적인 범위이며, 특정 시장이나 기간에 따라 크게 달라질 수 있다. 백테스트 결과를 그대로 미래에 적용할 수 없다.
다음 단계: 백테스팅에서 실전으로
백테스팅으로 유망한 전략을 발견했다면, 실전 적용까지는 다음 단계를 거쳐야 한다:
페이퍼 트레이딩: 최소 3~6개월 간 실제 시장에서 모의 매매를 실행한다. 백테스트와 페이퍼 트레이딩 결과의 차이가 크면 전략을 수정한다.
소액 실전 투입: 전체 투자금의 10~20%만으로 전략을 실행한다. 이 단계에서 체결 품질, 슬리피지, 운영 이슈를 확인한다.
점진적 확대: 3개월 이상 실전 결과가 백테스트와 유사하면 비중을 점진적으로 높인다.
지속적 모니터링: 전략의 팩터 노출, 롤링 샤프 비율, 드로다운을 실시간 모니터링한다. 전략이 "죽었다"는 판단 기준(예: 12개월 롤링 샤프가 0 미만으로 6개월 연속)을 사전에 정의한다.
투자 위험 고지
면책 조항: 이 글에 포함된 모든 코드와 전략은 교육 목적으로만 제공됩니다. 투자 권유가 아니며, 특정 투자 전략이나 종목에 대한 추천을 포함하지 않습니다. 모든 투자에는 원금 손실의 위험이 있으며, 과거 성과(백테스트 결과 포함)는 미래 수익을 보장하지 않습니다. 실제 투자 결정은 본인의 판단과 책임 하에 이루어져야 하며, 필요한 경우 공인 재무 전문가의 조언을 구하시기 바랍니다.
참고 자료
- Fama, E. F., & French, K. R. (2015). A five-factor asset pricing model. Journal of Financial Economics.
- Kenneth R. French Data Library - Factor Returns Data
- Enhanced Factor Investing in the Korean Stock Market (ScienceDirect)
- The Factor Mirage: How Quant Models Go Wrong - CFA Institute
- Seven Sins of Quantitative Investing - Deutsche Bank Research
- VectorBT Documentation - Lightning-fast Backtesting
- Backtesting.py - Backtest Trading Strategies in Python
- pykrx - 한국 주식 데이터 수집 라이브러리 (GitHub)
- 파이썬을 이용한 퀀트 투자 포트폴리오 만들기 (GitHub)
- Machine Learning for Factor Investing: Python Version - Oxford Academic (2025)