평균회귀(Mean Reversion) 자동매매란?
평균회귀 전략은 가격이 평균에서 벗어나면 다시 돌아온다는 통계적 성질을 이용한 퀀트 트레이딩 기법입니다. 주가, 코인, 환율 등 거의 모든 금융자산에 적용할 수 있으며, 모멘텀 전략과 반대되는 개념으로 과매수 시 매도, 과매도 시 매수하는 역추세 방식입니다.
특히 자동매매 봇으로 구현하면 감정 없이 기계적으로 진입·청산할 수 있어, 개인 투자자에게도 강력한 무기가 됩니다. 이 글에서는 파이썬으로 평균회귀 자동매매 시스템을 구축하는 전 과정을 다룹니다.
평균회귀 전략의 핵심 원리
평균회귀의 수학적 근거는 정상성(Stationarity)입니다. 정상 시계열은 평균과 분산이 시간에 따라 일정하므로, 가격이 평균에서 크게 벗어나면 되돌아올 확률이 높습니다.
| 구분 | 모멘텀 전략 | 평균회귀 전략 |
|---|---|---|
| 시장 가정 | 추세가 지속된다 | 가격이 평균으로 돌아온다 |
| 진입 시점 | 돌파 시 매수 | 과매도 시 매수 |
| 유리한 장세 | 강한 추세장 | 횡보·박스권 |
| 리스크 | 횡보장 휩소 | 추세장 역행 손실 |
핵심 지표로는 볼린저 밴드, Z-Score, RSI, 켈트너 채널 등이 있으며, 이들을 조합하면 신호의 신뢰도를 높일 수 있습니다.
Z-Score 기반 평균회귀 시그널
Z-Score는 현재 가격이 이동평균에서 표준편차 몇 배만큼 떨어져 있는지 나타냅니다. 절대값이 2 이상이면 통계적으로 극단 영역이므로 회귀 가능성이 높습니다.
import numpy as np
import pandas as pd
def calculate_zscore(series: pd.Series, window: int = 20) -> pd.Series:
"""이동평균 대비 Z-Score 계산"""
mean = series.rolling(window=window).mean()
std = series.rolling(window=window).std()
zscore = (series - mean) / std
return zscore
def generate_signals(df: pd.DataFrame,
entry_z: float = -2.0,
exit_z: float = 0.0) -> pd.DataFrame:
"""Z-Score 기반 매매 시그널 생성"""
df['zscore'] = calculate_zscore(df['close'])
df['signal'] = 0
df.loc[df['zscore'] <= entry_z, 'signal'] = 1 # 과매도 → 매수
df.loc[df['zscore'] >= -entry_z, 'signal'] = -1 # 과매수 → 매도
df.loc[abs(df['zscore']) <= abs(exit_z), 'signal'] = 0 # 평균 복귀 → 청산
return df
위 코드에서 entry_z = -2.0은 가격이 평균보다 2σ 아래일 때 매수, 반대로 +2σ일 때 매도합니다. exit_z = 0은 평균으로 돌아오면 포지션을 청산합니다.
볼린저 밴드 + Z-Score 복합 전략
Z-Score 단독보다 볼린저 밴드와 결합하면 거짓 신호를 크게 줄일 수 있습니다. 가격이 볼린저 하단 밴드 아래이면서 Z-Score도 -2 이하일 때만 진입하는 방식입니다.
def bollinger_zscore_strategy(df: pd.DataFrame,
bb_window: int = 20,
bb_std: float = 2.0,
z_threshold: float = -2.0) -> pd.DataFrame:
"""볼린저 밴드 + Z-Score 복합 전략"""
# 볼린저 밴드 계산
df['bb_mid'] = df['close'].rolling(bb_window).mean()
df['bb_upper'] = df['bb_mid'] + bb_std * df['close'].rolling(bb_window).std()
df['bb_lower'] = df['bb_mid'] - bb_std * df['close'].rolling(bb_window).std()
# Z-Score
df['zscore'] = calculate_zscore(df['close'], bb_window)
# 복합 시그널: 볼린저 하단 이탈 AND Z-Score 극단
df['signal'] = 0
buy_condition = (df['close'] < df['bb_lower']) & (df['zscore'] <= z_threshold)
sell_condition = (df['close'] > df['bb_upper']) & (df['zscore'] >= -z_threshold)
exit_condition = abs(df['zscore']) < 0.5
df.loc[buy_condition, 'signal'] = 1
df.loc[sell_condition, 'signal'] = -1
df.loc[exit_condition, 'signal'] = 0
return df
정상성 검정: ADF 테스트
평균회귀 전략을 적용하기 전, 해당 자산이 실제로 평균회귀 성질을 갖는지 검증해야 합니다. ADF(Augmented Dickey-Fuller) 테스트가 가장 널리 쓰입니다.
from statsmodels.tsa.stattools import adfuller
def check_mean_reversion(series: pd.Series, significance: float = 0.05) -> dict:
"""ADF 테스트로 평균회귀 가능성 검정"""
result = adfuller(series.dropna())
return {
'adf_statistic': result[0],
'p_value': result[1],
'is_stationary': result[1] < significance,
'half_life': calculate_half_life(series)
}
def calculate_half_life(series: pd.Series) -> float:
"""평균회귀 반감기 계산 (Ornstein-Uhlenbeck)"""
lag = series.shift(1).dropna()
delta = series.diff().dropna()
from sklearn.linear_model import LinearRegression
model = LinearRegression()
model.fit(lag.values.reshape(-1, 1), delta.values)
half_life = -np.log(2) / model.coef_[0]
return max(half_life, 1)
반감기(Half-Life)는 가격이 평균까지 절반 돌아오는 데 걸리는 기간입니다. 반감기가 너무 길면(30일 이상) 실전 트레이딩에 비효율적이고, 너무 짧으면(1~2일) 수수료 대비 수익이 낮을 수 있습니다. 5~20일이 이상적입니다.
자동매매 봇 구현
실전 자동매매 봇은 시그널 생성 → 리스크 관리 → 주문 실행의 파이프라인으로 동작합니다. 아래는 ccxt 라이브러리를 활용한 코인 자동매매 예시입니다.
import ccxt
import time
class MeanReversionBot:
def __init__(self, exchange_id: str, symbol: str,
timeframe: str = '1h', lookback: int = 20):
self.exchange = getattr(ccxt, exchange_id)({
'apiKey': 'YOUR_API_KEY',
'secret': 'YOUR_SECRET',
})
self.symbol = symbol
self.timeframe = timeframe
self.lookback = lookback
self.position = 0 # 현재 포지션
def fetch_data(self) -> pd.DataFrame:
"""OHLCV 데이터 조회"""
ohlcv = self.exchange.fetch_ohlcv(
self.symbol, self.timeframe, limit=self.lookback * 3
)
df = pd.DataFrame(ohlcv, columns=['ts','open','high','low','close','vol'])
return df
def execute(self):
"""메인 실행 루프"""
df = self.fetch_data()
df = bollinger_zscore_strategy(df)
latest = df.iloc[-1]
if latest['signal'] == 1 and self.position <= 0:
# 매수 진입
amount = self.calculate_position_size()
self.exchange.create_market_buy_order(self.symbol, amount)
self.position = 1
elif latest['signal'] == -1 and self.position >= 0:
# 매도 진입
amount = self.calculate_position_size()
self.exchange.create_market_sell_order(self.symbol, amount)
self.position = -1
elif latest['signal'] == 0 and self.position != 0:
# 포지션 청산
self.close_position()
self.position = 0
리스크 관리: 손절과 포지션 사이징
평균회귀 전략의 최대 위험은 평균이 이동하는 구조적 변화(레짐 전환)입니다. 반드시 손절을 설정해야 합니다.
| 리스크 관리 요소 | 권장 설정 | 설명 |
|---|---|---|
| 손절 Z-Score | ±3.0σ | 진입 후 추가 1σ 이탈 시 손절 |
| 최대 포지션 비율 | 총 자산의 5~10% | 단일 포지션 리스크 제한 |
| 최대 동시 포지션 | 3~5개 | 분산 투자 효과 |
| 최대 보유 기간 | 반감기 × 3 | 회귀 실패 시 타임아웃 청산 |
켈리 기준(Kelly Criterion)을 활용한 포지션 사이징도 효과적입니다. 승률과 손익비를 기반으로 최적 베팅 비율을 계산합니다.
def kelly_fraction(win_rate: float, avg_win: float, avg_loss: float) -> float:
"""켈리 기준 최적 포지션 비율"""
if avg_loss == 0:
return 0
b = avg_win / abs(avg_loss) # 손익비
f = (win_rate * b - (1 - win_rate)) / b
return max(0, min(f * 0.5, 0.25)) # Half-Kelly, 최대 25%
백테스트 결과 해석 시 주의점
평균회귀 전략의 백테스트에서 흔히 발생하는 함정들이 있습니다:
- 미래 정보 편향(Look-Ahead Bias): 이동평균 계산 시 미래 데이터가 포함되지 않도록 주의
- 생존 편향: 상장폐지된 종목을 제외하면 수익률이 과대평가됨
- 거래비용 무시: 평균회귀는 매매 빈도가 높아 수수료 영향이 큼
- 레짐 변화 무시: 횡보장에서만 유효하므로 추세장 필터 필수
관련하여 백테스트 과최적화 방지법과 시장 레짐 감지 퀀트 전략도 함께 참고하시면 더 견고한 시스템을 구축할 수 있습니다.
실전 적용 팁
평균회귀 자동매매를 실전에 적용할 때 기억해야 할 핵심 포인트입니다:
- 페어 트레이딩과 결합: 단일 자산보다 상관관계 높은 두 자산의 스프레드에 적용하면 정상성이 더 높음
- 다중 타임프레임 확인: 1시간봉에서 과매도여도 일봉이 하락 추세면 진입 보류
- 변동성 필터: ATR이 극단적으로 높을 때는 레짐 전환 가능성이 있으므로 진입 자제
- 점진적 진입: Z-Score -2에서 전량 매수 대신, -2, -2.5, -3에서 분할 매수
- ADF 재검정: 주기적으로(월 1회) 정상성을 재검증하여 전략 유효성 확인
평균회귀 전략은 단순하지만, 정상성 검증 → 시그널 설계 → 리스크 관리 → 자동화라는 체계적 프로세스를 따르면 안정적인 수익원이 될 수 있습니다. 특히 횡보장이 길어지는 구간에서 모멘텀 전략의 보완재로 활용하면 포트폴리오 전체의 샤프 비율을 개선할 수 있습니다.