평균회귀 전략이란?
평균회귀(Mean Reversion)는 가격이 평균에서 벗어나면 다시 평균으로 돌아온다는 통계적 성질을 이용한 매매 전략입니다. 주가가 급락하면 매수하고, 급등하면 매도하는 역추세(Counter-Trend) 접근법입니다. 모멘텀 전략과 정반대 논리이며, 횡보장과 단기 매매에서 특히 강력한 성과를 보입니다.
이 글에서는 파이썬으로 평균회귀 전략을 처음부터 끝까지 구현합니다. 정상성 검정, 다양한 진입 시그널, 백테스트, 필터링 기법까지 실전에서 바로 활용할 수 있는 코드를 제공합니다.
평균회귀 가능 여부 검정
모든 자산이 평균회귀하는 것은 아닙니다. 전략 적용 전에 ADF(Augmented Dickey-Fuller) 검정과 허스트 지수(Hurst Exponent)로 평균회귀 성질을 확인해야 합니다.
import numpy as np
import pandas as pd
import yfinance as yf
from statsmodels.tsa.stattools import adfuller
def test_mean_reversion(prices):
"""평균회귀 가능 여부 검정"""
# 1) ADF 검정
adf_stat, adf_pvalue, _, _, _, _ = adfuller(prices.dropna())
# 2) 허스트 지수
hurst = compute_hurst(prices.dropna().values)
# 3) 반감기 (평균 회귀 속도)
half_life = compute_half_life(prices.dropna())
print(f"=== 평균회귀 검정 ===")
print(f"ADF 통계량: {adf_stat:.4f} (p-value: {adf_pvalue:.4f})")
print(f" → {'정상 시계열 (평균회귀 O)' if adf_pvalue < 0.05 else '비정상 시계열 (평균회귀 X)'}")
print(f"허스트 지수: {hurst:.4f}")
print(f" → {'평균회귀' if hurst < 0.5 else '추세 추종' if hurst > 0.5 else '랜덤워크'}")
print(f"반감기: {half_life:.1f}일")
return adf_pvalue, hurst, half_life
def compute_hurst(ts):
"""허스트 지수 계산 (R/S 분석)"""
lags = range(2, min(100, len(ts) // 2))
rs = []
for lag in lags:
chunks = [ts[i:i+lag] for i in range(0, len(ts)-lag, lag)]
rs_values = []
for chunk in chunks:
mean_val = np.mean(chunk)
deviate = np.cumsum(chunk - mean_val)
r = np.max(deviate) - np.min(deviate)
s = np.std(chunk, ddof=1)
if s > 0:
rs_values.append(r / s)
if rs_values:
rs.append(np.mean(rs_values))
else:
rs.append(np.nan)
valid = [(l, r) for l, r in zip(lags, rs) if not np.isnan(r) and r > 0]
if len(valid) < 2:
return 0.5
log_lags = np.log([v[0] for v in valid])
log_rs = np.log([v[1] for v in valid])
hurst = np.polyfit(log_lags, log_rs, 1)[0]
return hurst
def compute_half_life(prices):
"""OU 과정 반감기 추정"""
lag = prices.shift(1).dropna()
delta = prices.diff().dropna()
idx = lag.index.intersection(delta.index)
from sklearn.linear_model import LinearRegression
model = LinearRegression()
model.fit(lag[idx].values.reshape(-1, 1), delta[idx].values)
theta = -model.coef_[0]
half_life = np.log(2) / theta if theta > 0 else float('inf')
return half_life
# 예시: 스프레드 또는 개별 자산
data = yf.download('SPY', start='2020-01-01', end='2025-12-31')
prices = data['Close']
adf_p, hurst, hl = test_mean_reversion(prices)
허스트 지수가 0.5 미만이면 평균회귀 성향, 0.5 초과면 추세 추종 성향입니다. 개별 주가 자체는 보통 비정상이므로, 가격 차이(스프레드)나 기술적 지표에 적용하는 것이 일반적입니다.
전략 1: Z-Score 평균회귀
가격의 Z-Score가 극단값에 도달하면 역방향으로 진입하는 가장 기본적인 평균회귀 전략입니다.
def zscore_mean_reversion(prices, lookback=20, entry_z=2.0,
exit_z=0.0, stop_z=3.5):
"""Z-Score 기반 평균회귀 전략"""
returns = prices.pct_change()
mean = prices.rolling(lookback).mean()
std = prices.rolling(lookback).std()
zscore = (prices - mean) / std
position = pd.Series(0.0, index=prices.index)
for i in range(1, len(prices)):
z = zscore.iloc[i]
prev_pos = position.iloc[i-1]
# 손절
if abs(z) > stop_z:
position.iloc[i] = 0
# 청산
elif prev_pos > 0 and z >= exit_z:
position.iloc[i] = 0
elif prev_pos < 0 and z <= exit_z:
position.iloc[i] = 0
# 신규 진입
elif z < -entry_z and prev_pos == 0:
position.iloc[i] = 1 # 매수
elif z > entry_z and prev_pos == 0:
position.iloc[i] = -1 # 매도
else:
position.iloc[i] = prev_pos
strategy_returns = position.shift(1) * returns
return strategy_returns, position, zscore
strat_ret, pos, zscore = zscore_mean_reversion(prices)
sharpe = strat_ret.mean() / strat_ret.std() * np.sqrt(252)
total = (1 + strat_ret).cumprod().iloc[-1] - 1
print(f"Z-Score 전략 샤프: {sharpe:.2f}")
print(f"총 수익률: {total:.2%}")
전략 2: 볼린저 밴드 + RSI 복합 필터
볼린저 밴드의 밴드 터치와 RSI 과매수/과매도를 결합하면 시그널 정확도가 크게 향상됩니다.
def bollinger_rsi_reversion(prices, bb_window=20, bb_std=2.0,
rsi_window=14, rsi_ob=70, rsi_os=30):
"""볼린저 밴드 + RSI 복합 평균회귀"""
returns = prices.pct_change()
# 볼린저 밴드
bb_mid = prices.rolling(bb_window).mean()
bb_upper = bb_mid + bb_std * prices.rolling(bb_window).std()
bb_lower = bb_mid - bb_std * prices.rolling(bb_window).std()
# RSI
delta = prices.diff()
gain = delta.clip(lower=0).rolling(rsi_window).mean()
loss = (-delta.clip(upper=0)).rolling(rsi_window).mean()
rs = gain / loss
rsi = 100 - (100 / (1 + rs))
position = pd.Series(0.0, index=prices.index)
for i in range(1, len(prices)):
prev_pos = position.iloc[i-1]
p = prices.iloc[i]
r = rsi.iloc[i]
# 매수: 하단 밴드 터치 + RSI 과매도
if p <= bb_lower.iloc[i] and r < rsi_os and prev_pos <= 0:
position.iloc[i] = 1
# 매도: 상단 밴드 터치 + RSI 과매수
elif p >= bb_upper.iloc[i] and r > rsi_ob and prev_pos >= 0:
position.iloc[i] = -1
# 청산: 중심선 복귀
elif prev_pos > 0 and p >= bb_mid.iloc[i]:
position.iloc[i] = 0
elif prev_pos < 0 and p <= bb_mid.iloc[i]:
position.iloc[i] = 0
else:
position.iloc[i] = prev_pos
strategy_returns = position.shift(1) * returns
return strategy_returns, position
bb_rsi_ret, bb_rsi_pos = bollinger_rsi_reversion(prices)
sharpe = bb_rsi_ret.mean() / bb_rsi_ret.std() * np.sqrt(252)
print(f"볼린저+RSI 전략 샤프: {sharpe:.2f}")
RSI 자동매매 전략에서 RSI 단독 전략을 다뤘지만, 볼린저 밴드와 결합하면 거짓 시그널을 크게 줄일 수 있습니다.
전략 3: 반감기 적응형 룩백
OU 과정의 반감기(half-life)를 룩백 윈도우로 사용하면 자산의 평균회귀 속도에 맞는 최적 파라미터를 자동으로 설정할 수 있습니다.
from sklearn.linear_model import LinearRegression
def adaptive_mean_reversion(prices, min_hl=5, max_hl=120,
recalc_period=60):
"""반감기 적응형 평균회귀 전략"""
returns = prices.pct_change()
position = pd.Series(0.0, index=prices.index)
half_lives = pd.Series(np.nan, index=prices.index)
for i in range(max_hl, len(prices)):
# 주기적 반감기 재계산
if i % recalc_period == 0 or np.isnan(half_lives.iloc[i-1]):
window_prices = prices.iloc[max(0, i-252):i]
hl = compute_half_life(window_prices)
hl = np.clip(hl, min_hl, max_hl)
else:
hl = half_lives.iloc[i-1]
half_lives.iloc[i] = hl
lookback = int(round(hl))
# 적응형 Z-Score
recent = prices.iloc[max(0, i-lookback):i+1]
z = (prices.iloc[i] - recent.mean()) / recent.std()
prev_pos = position.iloc[i-1]
if z < -2.0 and prev_pos <= 0:
position.iloc[i] = 1
elif z > 2.0 and prev_pos >= 0:
position.iloc[i] = -1
elif prev_pos > 0 and z >= 0:
position.iloc[i] = 0
elif prev_pos < 0 and z <= 0:
position.iloc[i] = 0
else:
position.iloc[i] = prev_pos
strategy_returns = position.shift(1) * returns
return strategy_returns, position, half_lives
adapt_ret, adapt_pos, hls = adaptive_mean_reversion(prices)
sharpe = adapt_ret.mean() / adapt_ret.std() * np.sqrt(252)
print(f"적응형 전략 샤프: {sharpe:.2f}")
print(f"평균 반감기: {hls.dropna().mean():.1f}일")
레짐 필터: 추세장에서 평균회귀 끄기
평균회귀 전략의 가장 큰 약점은 강한 추세장에서의 손실입니다. 시장 레짐을 판별하여 추세 구간에서는 전략을 비활성화하면 성과가 크게 개선됩니다.
def regime_filter(prices, adx_window=14, adx_threshold=25,
trend_ma=200):
"""
레짐 필터: 추세장 판별
- ADX > threshold: 강한 추세 → 평균회귀 비활성
- 200일 이평선 기울기: 추세 방향 확인
"""
# ADX 계산 (간소화 버전)
high = prices # 종가 기반 근사
low = prices
close = prices
plus_dm = prices.diff().clip(lower=0)
minus_dm = (-prices.diff()).clip(lower=0)
atr = prices.diff().abs().rolling(adx_window).mean()
plus_di = (plus_dm.rolling(adx_window).mean() / atr) * 100
minus_di = (minus_dm.rolling(adx_window).mean() / atr) * 100
dx = (abs(plus_di - minus_di) / (plus_di + minus_di)) * 100
adx = dx.rolling(adx_window).mean()
# 200일 이평선 기울기
ma200 = prices.rolling(trend_ma).mean()
ma_slope = ma200.pct_change(20) # 20일간 기울기
# 평균회귀 적합 구간: ADX 낮고 이평선 기울기 완만
is_ranging = (adx < adx_threshold) & (abs(ma_slope) < 0.02)
return is_ranging, adx
ranging, adx = regime_filter(prices)
print(f"횡보장 비율: {ranging.mean():.1%}")
전략별 성과 비교 프레임워크
| 전략 | 특징 | 적합 시장 | 난이도 |
|---|---|---|---|
| Z-Score 기본형 | 단순, 빠른 구현 | 횡보장, 스프레드 | ★☆☆ |
| 볼린저+RSI 복합 | 거짓 시그널 감소 | 개별 주식 | ★★☆ |
| 반감기 적응형 | 자동 파라미터 조절 | 다양한 자산 | ★★★ |
| 레짐 필터 결합 | 추세장 손실 방지 | 전 시장 | ★★★ |
실전 적용 핵심 포인트
- 평균회귀 검정이 먼저다: 검정 없이 무작정 역추세 매매하면 추세장에서 큰 손실을 입습니다. 반드시 ADF 검정과 허스트 지수를 확인하세요.
- 손절은 필수다: 평균으로 돌아오지 않는 경우(구조적 변화)가 존재합니다. Z-Score 3.5~4.0 수준의 손절선을 반드시 설정하세요.
- 거래 비용에 민감하다: 평균회귀는 빈번한 매매가 필요하므로 수수료와 슬리피지의 영향이 큽니다.
- 레짐 필터가 핵심이다: 추세장에서 평균회귀를 끄는 것만으로도 전체 성과가 크게 개선됩니다.
- 멀티 자산으로 분산하라: 한 자산에 집중하지 말고 여러 자산에서 평균회귀 기회를 찾아 분산하세요.
최대낙폭 MDD 관리 전략과 결합하면 평균회귀 실패 시의 손실을 체계적으로 제한할 수 있습니다.
마무리
평균회귀는 퀀트 매매의 양대 축 중 하나입니다(다른 하나는 모멘텀). 핵심은 "이 자산이 정말 평균으로 돌아오는가"를 통계적으로 검증하는 것입니다. 검정을 통과한 자산에 적절한 시그널과 리스크 관리를 적용하면, 시장 방향에 관계없이 안정적인 수익 기회를 확보할 수 있습니다. 위 코드를 기반으로 자신만의 유니버스에서 평균회귀 기회를 탐색해 보세요.