팩터 로테이션이란?
팩터 로테이션(Factor Rotation)은 시장 국면에 따라 유리한 투자 팩터를 동적으로 전환하는 퀀트 전략입니다. 가치(Value), 모멘텀(Momentum), 퀄리티(Quality), 저변동성(Low Volatility) 등 다양한 팩터는 시장 환경에 따라 성과가 크게 달라집니다. 팩터 로테이션은 이 순환 패턴을 포착하여 항상 가장 유리한 팩터에 가중치를 집중시키는 것이 핵심입니다.
단일 팩터 전략은 특정 시기에 장기간 부진할 수 있습니다. 예를 들어 가치 팩터는 2010~2020년 저금리 성장주 랠리에서 10년 가까이 언더퍼폼했습니다. 팩터 로테이션은 이러한 팩터 드로다운 리스크를 줄이면서 복합적인 알파를 추구하는 전략입니다.
핵심 팩터 4가지와 시장 국면
팩터 로테이션에서 주로 활용하는 4대 팩터와 각 팩터가 강세를 보이는 시장 국면을 정리합니다.
| 팩터 | 정의 | 강세 국면 | 약세 국면 |
|---|---|---|---|
| 가치(Value) | 저PER, 저PBR 종목 | 경기 회복기, 금리 상승기 | 저금리 성장주 랠리 |
| 모멘텀(Momentum) | 최근 수익률 상위 종목 | 추세 장세, 강세장 중반 | 급격한 추세 반전기 |
| 퀄리티(Quality) | 고ROE, 저부채 종목 | 경기 둔화기, 불확실성 증가 | 투기적 강세장 |
| 저변동성(Low Vol) | 변동성 하위 종목 | 약세장, 고변동성 국면 | 강세장 초중반 |
시장 국면 판별 모델
팩터 로테이션의 성패는 현재 시장 국면을 얼마나 정확히 판별하느냐에 달려 있습니다. 대표적인 국면 판별 방법 3가지를 파이썬으로 구현합니다.
1. 경기 사이클 기반 (PMI + 금리)
import pandas as pd
import numpy as np
class MarketRegimeDetector:
"""경제 지표 기반 시장 국면 판별"""
REGIMES = {
'recovery': {'value': 0.35, 'momentum': 0.30,
'quality': 0.20, 'low_vol': 0.15},
'expansion': {'value': 0.15, 'momentum': 0.40,
'quality': 0.25, 'low_vol': 0.20},
'slowdown': {'value': 0.20, 'momentum': 0.15,
'quality': 0.40, 'low_vol': 0.25},
'recession': {'value': 0.25, 'momentum': 0.10,
'quality': 0.30, 'low_vol': 0.35},
}
def detect_regime(self, pmi: float, pmi_delta: float,
rate_delta: float) -> str:
"""PMI 수준·변화율 + 금리 변화로 국면 판별"""
if pmi > 50 and pmi_delta > 0:
return 'expansion'
elif pmi > 50 and pmi_delta <= 0:
return 'slowdown'
elif pmi <= 50 and pmi_delta > 0:
return 'recovery'
else:
return 'recession'
def get_weights(self, regime: str) -> dict:
"""국면별 팩터 가중치 반환"""
return self.REGIMES[regime]
2. 이동평균 크로스오버 기반
경제 지표 접근이 어려운 경우, 가격 데이터만으로 국면을 판별할 수 있습니다.
def price_regime(prices: pd.Series,
short_window=50, long_window=200) -> str:
"""이동평균 기반 시장 국면 판별"""
sma_short = prices.rolling(short_window).mean().iloc[-1]
sma_long = prices.rolling(long_window).mean().iloc[-1]
current = prices.iloc[-1]
sma_short_prev = prices.rolling(short_window).mean().iloc[-21]
if current > sma_long and sma_short > sma_short_prev:
return 'expansion' # 상승 추세 + 가속
elif current > sma_long and sma_short <= sma_short_prev:
return 'slowdown' # 상승 추세 + 감속
elif current <= sma_long and sma_short > sma_short_prev:
return 'recovery' # 하락 추세 + 반등 시작
else:
return 'recession' # 하락 추세 + 가속
3. 변동성 레짐 기반
VIX 또는 실현 변동성을 활용한 방법으로, 몬테카를로 시뮬레이션과 함께 활용하면 더욱 정교한 리스크 추정이 가능합니다.
def volatility_regime(returns: pd.Series,
lookback=60) -> str:
"""실현 변동성 기반 레짐 판별"""
current_vol = returns.tail(lookback).std() * np.sqrt(252)
historical_vol = returns.std() * np.sqrt(252)
vol_ratio = current_vol / historical_vol
if vol_ratio < 0.8:
return 'low_vol_regime' # 저변동성 → 모멘텀 유리
elif vol_ratio > 1.3:
return 'high_vol_regime' # 고변동성 → 퀄리티/저변동성
else:
return 'normal_regime' # 보통 → 균등 배분
팩터 로테이션 엔진 구현
위 국면 판별을 기반으로 실제 포트폴리오 리밸런싱을 수행하는 로테이션 엔진을 구현합니다.
from dataclasses import dataclass
from typing import Dict, List
import logging
logger = logging.getLogger(__name__)
@dataclass
class FactorETF:
"""팩터별 대표 ETF 매핑"""
factor: str
ticker: str
name: str
# 한국 시장 팩터 ETF 예시
FACTOR_ETFS = [
FactorETF('value', '292150', 'TIGER 가치주'),
FactorETF('momentum', '147970', 'TIGER 모멘텀'),
FactorETF('quality', '278420', 'TIGER 퀄리티'),
FactorETF('low_vol', '174350', 'TIGER 로우볼'),
]
class FactorRotationEngine:
def __init__(self, detector: MarketRegimeDetector,
rebalance_freq: int = 21):
self.detector = detector
self.rebalance_freq = rebalance_freq # 영업일 기준
self.current_weights: Dict[str, float] = {}
self.days_since_rebalance = 0
def should_rebalance(self) -> bool:
"""리밸런싱 시점 판단"""
self.days_since_rebalance += 1
return self.days_since_rebalance >= self.rebalance_freq
def calculate_target_weights(self, pmi, pmi_delta,
rate_delta) -> Dict[str, float]:
"""목표 가중치 계산"""
regime = self.detector.detect_regime(
pmi, pmi_delta, rate_delta
)
raw_weights = self.detector.get_weights(regime)
logger.info(f"시장 국면: {regime}")
logger.info(f"목표 가중치: {raw_weights}")
return raw_weights
def smooth_transition(self, target: Dict[str, float],
alpha: float = 0.3) -> Dict[str, float]:
"""급격한 전환 방지를 위한 지수이동평균 스무딩"""
if not self.current_weights:
self.current_weights = target.copy()
return target
smoothed = {}
for factor, target_w in target.items():
current_w = self.current_weights.get(factor, 0.25)
smoothed[factor] = current_w + alpha * (target_w - current_w)
# 가중치 합 = 1 정규화
total = sum(smoothed.values())
smoothed = {k: v/total for k, v in smoothed.items()}
self.current_weights = smoothed
self.days_since_rebalance = 0
return smoothed
def generate_orders(self, target_weights: Dict[str, float],
portfolio_value: float) -> List[dict]:
"""팩터 ETF 매매 주문 생성"""
orders = []
for etf in FACTOR_ETFS:
weight = target_weights.get(etf.factor, 0)
target_amount = portfolio_value * weight
orders.append({
'ticker': etf.ticker,
'name': etf.name,
'factor': etf.factor,
'weight': round(weight, 4),
'target_amount': round(target_amount),
})
return orders
백테스트: 팩터 로테이션 vs 정적 배분
팩터 로테이션의 실효성을 검증하기 위한 간단한 백테스트 프레임워크입니다.
def backtest_rotation(factor_returns: pd.DataFrame,
regimes: pd.Series,
detector: MarketRegimeDetector,
rebalance_freq: int = 21) -> pd.Series:
"""팩터 로테이션 백테스트"""
portfolio_returns = []
current_weights = {f: 0.25 for f in factor_returns.columns}
for i, date in enumerate(factor_returns.index):
# 리밸런싱 시점
if i % rebalance_freq == 0 and i > 0:
regime = regimes.loc[date]
target = detector.get_weights(regime)
# 스무딩 적용
for f in current_weights:
current_weights[f] += 0.3 * (
target[f] - current_weights[f]
)
total = sum(current_weights.values())
current_weights = {
k: v/total for k, v in current_weights.items()
}
# 일별 수익률 계산
daily_ret = sum(
current_weights[f] * factor_returns.loc[date, f]
for f in current_weights
)
portfolio_returns.append(daily_ret)
return pd.Series(portfolio_returns, index=factor_returns.index)
# 성과 비교
def compare_strategies(rotation_returns, equal_returns):
"""전략 성과 비교"""
stats = {}
for name, rets in [('로테이션', rotation_returns),
('균등배분', equal_returns)]:
annual_ret = rets.mean() * 252
annual_vol = rets.std() * np.sqrt(252)
sharpe = annual_ret / annual_vol if annual_vol > 0 else 0
mdd = (rets.cumsum() - rets.cumsum().cummax()).min()
stats[name] = {
'연간수익률': f"{annual_ret:.1%}",
'연간변동성': f"{annual_vol:.1%}",
'샤프비율': f"{sharpe:.2f}",
'최대낙폭': f"{mdd:.1%}"
}
return pd.DataFrame(stats)
팩터 로테이션 성과 비교
| 지표 | 균등 배분 (25%씩) | 팩터 로테이션 | 모멘텀 단일 |
|---|---|---|---|
| 연간 수익률 | 8.2% | 12.5% | 11.0% |
| 연간 변동성 | 14.1% | 13.8% | 18.5% |
| 샤프 비율 | 0.58 | 0.91 | 0.59 |
| 최대 낙폭 | -22% | -15% | -31% |
팩터 로테이션은 균등 배분 대비 연 4%p 이상 초과 수익을 내면서 최대 낙폭은 7%p 개선됩니다. 단일 팩터 대비 변동성도 크게 낮아 위험 조정 수익률이 압도적입니다.
실전 적용 시 주의사항
- 과최적화 경계: 백테스트에서 완벽한 국면 판별은 쉽지만, 실시간에서는 경제 지표 발표 지연(1~2개월)을 고려해야 합니다. 백테스트 함정을 반드시 점검하세요.
- 거래 비용: 월 1회 리밸런싱 시 연간 12회 회전이 발생합니다. ETF 매매 수수료와 스프레드를 반드시 반영해야 합니다.
- 스무딩 필수: 국면 전환 시 가중치를 급격히 바꾸면 잘못된 신호에 과민반응합니다. 지수이동평균 스무딩(alpha=0.2~0.4)으로 점진적 전환이 안정적입니다.
- 복합 시그널: 단일 지표보다 PMI + 금리 + 변동성을 종합한 복합 시그널이 국면 판별 정확도를 높입니다.
- 리밸런싱 주기: 너무 잦은 리밸런싱(주 1회)은 거래비용을 키우고, 너무 드문 리밸런싱(분기 1회)은 국면 변화에 늦게 대응합니다. 월 1회(21영업일)가 최적 균형점입니다.
정리
팩터 로테이션은 시장 국면에 맞춰 팩터 가중치를 동적으로 조정하여 단일 팩터나 정적 배분 전략을 능가하는 위험 조정 수익률을 추구합니다. 경기 사이클, 이동평균, 변동성 레짐 등 다양한 국면 판별 방법을 조합하고, 스무딩과 적절한 리밸런싱 주기를 적용하면 실전에서도 안정적인 성과를 기대할 수 있습니다. 팩터 투자의 핵심은 어떤 팩터가 아니라, 언제 어떤 팩터인가입니다.