파이썬 평균회귀 자동매매 전략

평균회귀 전략이란?

평균회귀(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 관리 전략과 결합하면 평균회귀 실패 시의 손실을 체계적으로 제한할 수 있습니다.

마무리

평균회귀는 퀀트 매매의 양대 축 중 하나입니다(다른 하나는 모멘텀). 핵심은 "이 자산이 정말 평균으로 돌아오는가"를 통계적으로 검증하는 것입니다. 검정을 통과한 자산에 적절한 시그널과 리스크 관리를 적용하면, 시장 방향에 관계없이 안정적인 수익 기회를 확보할 수 있습니다. 위 코드를 기반으로 자신만의 유니버스에서 평균회귀 기회를 탐색해 보세요.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux