파이썬 페어 트레이딩 전략: 공적분 검정

페어 트레이딩이란 무엇인가?

페어 트레이딩(Pairs Trading)은 통계적 차익거래(Statistical Arbitrage)의 대표적인 전략으로, 역사적으로 높은 상관관계를 가진 두 종목의 가격 괴리가 발생했을 때 이를 이용하여 수익을 추구합니다. 1980년대 모건 스탠리(Morgan Stanley)의 퀀트 팀이 체계화한 이 전략은 시장 방향에 무관한 마켓 뉴트럴(market-neutral) 포지션을 구축하므로, 상승장과 하락장 모두에서 수익 기회를 제공합니다.

핵심 아이디어는 간단합니다. 삼성전자와 SK하이닉스처럼 같은 산업에 속한 두 종목은 장기적으로 비슷하게 움직이는 경향이 있습니다. 일시적으로 한 종목이 상대적으로 과대평가되고 다른 종목이 과소평가되면, 고평가 종목을 공매도하고 저평가 종목을 매수하여 가격이 다시 수렴할 때 양쪽 모두에서 수익을 얻습니다.

공적분(Cointegration)과 상관관계의 차이

페어 트레이딩에서 가장 중요한 통계적 개념은 공적분(cointegration)입니다. 단순 상관관계와 혼동하기 쉽지만, 둘은 본질적으로 다릅니다.

  • 상관관계(Correlation): 두 시계열의 수익률이 같은 방향으로 움직이는 정도. 높은 상관관계가 있어도 가격 차이가 벌어진 후 다시 좁혀진다는 보장이 없습니다.
  • 공적분(Cointegration): 두 시계열의 선형 결합이 정상성(stationarity)을 갖는 관계. 즉, 가격 차이(스프레드)가 일정 범위 내에서 평균으로 회귀하는 성질이 있습니다.

비유하자면, 상관관계는 “두 사람이 같은 방향으로 걷는다”는 것이고, 공적분은 “두 사람이 끈으로 연결되어 있어서 아무리 멀어져도 다시 가까워진다”는 것입니다. 페어 트레이딩에서는 반드시 공적분 관계가 검증된 페어만 거래해야 합니다.

파이썬으로 페어 선정하기

공적분 페어를 찾기 위해 엥글-그레인저(Engle-Granger) 검정요한슨(Johansen) 검정을 활용합니다.

import numpy as np
import pandas as pd
import yfinance as yf
from statsmodels.tsa.stattools import coint, adfuller
from itertools import combinations

def find_cointegrated_pairs(tickers: list, start: str, end: str,
                            significance: float = 0.05) -> list:
    """공적분 검정으로 페어 후보 탐색"""
    prices = yf.download(tickers, start=start, end=end)['Close']
    prices = prices.dropna(axis=1)

    pairs = []
    n = len(prices.columns)

    for i, j in combinations(range(n), 2):
        stock1 = prices.columns[i]
        stock2 = prices.columns[j]

        # 엥글-그레인저 공적분 검정
        score, p_value, _ = coint(prices[stock1], prices[stock2])

        if p_value < significance:
            # 상관계수도 함께 확인
            corr = prices[stock1].pct_change().corr(
                prices[stock2].pct_change()
            )
            pairs.append({
                'stock1': stock1,
                'stock2': stock2,
                'p_value': round(p_value, 4),
                'correlation': round(corr, 4)
            })

    return sorted(pairs, key=lambda x: x['p_value'])

검정 시 주의할 점:

  • 유의수준 0.05 이하인 페어만 선택하되, 0.01 이하면 더욱 신뢰할 수 있습니다.
  • 같은 섹터 내에서 페어를 찾으면 경제적 논리(fundamental reasoning)가 뒷받침되어 관계의 지속성이 높습니다.
  • 롤링 윈도우로 공적분 관계의 안정성을 확인하세요. 특정 기간에만 공적분이 성립하고 다른 기간에는 깨지는 페어는 피해야 합니다.

스프레드 계산과 Z-Score

페어가 선정되면 두 종목 간의 스프레드(spread)를 계산하고, 이를 표준화한 Z-Score로 매매 시점을 판단합니다.

from sklearn.linear_model import LinearRegression

def calculate_spread(prices1: pd.Series, prices2: pd.Series,
                     window: int = 60) -> pd.DataFrame:
    """헤지 비율 기반 스프레드 및 Z-Score 계산"""
    # OLS 회귀로 헤지 비율(beta) 추정
    model = LinearRegression()
    model.fit(prices2.values.reshape(-1, 1), prices1.values)
    hedge_ratio = model.coef_[0]

    # 스프레드 = stock1 - beta * stock2
    spread = prices1 - hedge_ratio * prices2

    # 롤링 Z-Score
    spread_mean = spread.rolling(window=window).mean()
    spread_std = spread.rolling(window=window).std()
    z_score = (spread - spread_mean) / spread_std

    return pd.DataFrame({
        'spread': spread,
        'z_score': z_score,
        'hedge_ratio': hedge_ratio,
        'spread_mean': spread_mean,
        'upper_band': spread_mean + 2 * spread_std,
        'lower_band': spread_mean - 2 * spread_std
    })

헤지 비율(hedge ratio)은 두 종목 간의 적정 매매 비율을 결정합니다. 예를 들어 헤지 비율이 0.8이라면, stock1을 1주 매수할 때 stock2를 0.8주 공매도해야 시장 리스크를 중립화할 수 있습니다.

Z-Score의 해석:

  • Z-Score > +2: 스프레드가 평균 대비 비정상적으로 넓어짐 → stock1 고평가, stock2 저평가
  • Z-Score < -2: 스프레드가 평균 대비 비정상적으로 좁아짐 → stock1 저평가, stock2 고평가
  • Z-Score ≈ 0: 스프레드가 정상 범위 → 포지션 청산

자동매매 시스템 구현

Z-Score 기반으로 완전 자동화된 페어 트레이딩 백테스팅 엔진을 구축합니다.

class PairsTradingStrategy:
    """페어 트레이딩 백테스팅 엔진"""

    def __init__(self, stock1: str, stock2: str,
                 start: str, end: str,
                 z_entry: float = 2.0, z_exit: float = 0.5,
                 z_stop: float = 3.5, lookback: int = 60):
        self.stock1 = stock1
        self.stock2 = stock2
        self.z_entry = z_entry
        self.z_exit = z_exit
        self.z_stop = z_stop
        self.lookback = lookback
        self.df = self._prepare(start, end)

    def _prepare(self, start: str, end: str) -> pd.DataFrame:
        data = yf.download([self.stock1, self.stock2],
                           start=start, end=end)['Close']
        spread_df = calculate_spread(
            data[self.stock1], data[self.stock2], self.lookback
        )
        df = pd.concat([data, spread_df], axis=1)
        return df.dropna()

    def generate_signals(self) -> pd.DataFrame:
        """Z-Score 기반 매매 신호 생성"""
        df = self.df.copy()
        df['position'] = 0  # 1: long spread, -1: short spread

        position = 0
        for i in range(1, len(df)):
            z = df['z_score'].iloc[i]

            if position == 0:
                # 진입: 스프레드 확대
                if z > self.z_entry:
                    position = -1  # short spread (sell s1, buy s2)
                elif z < -self.z_entry:
                    position = 1   # long spread (buy s1, sell s2)

            elif position == 1:
                # 청산: 평균 회귀 또는 손절
                if z > -self.z_exit or z < -self.z_stop:
                    position = 0

            elif position == -1:
                if z < self.z_exit or z > self.z_stop:
                    position = 0

            df.iloc[i, df.columns.get_loc('position')] = position

        return df

    def backtest(self, capital: float = 10_000_000,
                 commission: float = 0.00015) -> dict:
        """백테스팅 실행"""
        df = self.generate_signals()
        df['pos_change'] = df['position'].diff().fillna(0)

        # 스프레드 수익률 계산
        hr = df['hedge_ratio'].iloc[0]
        df['s1_ret'] = df[self.stock1].pct_change()
        df['s2_ret'] = df[self.stock2].pct_change()
        df['spread_ret'] = df['s1_ret'] - hr * df['s2_ret']

        # 포지션 기반 수익
        df['strategy_ret'] = df['position'].shift(1) * df['spread_ret']

        # 거래 비용 차감
        df['trade_cost'] = df['pos_change'].abs() * commission * 2
        df['net_ret'] = df['strategy_ret'] - df['trade_cost']

        # 누적 수익
        df['cumulative'] = (1 + df['net_ret']).cumprod()
        final_value = capital * df['cumulative'].iloc[-1]

        # 최대 낙폭(MDD)
        cummax = df['cumulative'].cummax()
        drawdown = (df['cumulative'] - cummax) / cummax
        max_dd = drawdown.min() * 100

        # 샤프 비율 (연율화)
        sharpe = (df['net_ret'].mean() / df['net_ret'].std()
                  * np.sqrt(252)) if df['net_ret'].std() > 0 else 0

        trades = (df['pos_change'] != 0).sum()

        return {
            'total_return_pct': round(
                (final_value - capital) / capital * 100, 2
            ),
            'final_value': round(final_value),
            'sharpe_ratio': round(sharpe, 2),
            'max_drawdown_pct': round(max_dd, 2),
            'num_trades': int(trades),
            'avg_holding_days': round(len(df) / max(trades / 2, 1), 1)
        }

이 엔진의 핵심 설계 포인트:

  • z_entry (진입 임계값): 스프레드가 충분히 벌어진 후 진입하여 수익 공간을 확보합니다. 일반적으로 1.5~2.5 사이를 사용합니다.
  • z_exit (청산 임계값): 스프레드가 평균 근처로 돌아오면 수익을 확정합니다. 0~0.5 범위가 보편적입니다.
  • z_stop (손절 임계값): 스프레드가 더 벌어져 공적분 관계가 깨질 수 있을 때 손실을 제한합니다. 3~4 범위를 권장합니다.

롤링 공적분 검정으로 관계 모니터링

공적분 관계는 영원하지 않습니다. 기업의 사업 구조 변화, M&A, 산업 재편 등으로 인해 관계가 깨질 수 있으므로 지속적인 모니터링이 필수입니다.

def rolling_cointegration(prices1: pd.Series, prices2: pd.Series,
                          window: int = 252) -> pd.Series:
    """롤링 윈도우 공적분 검정 (1년 = 252 거래일)"""
    p_values = pd.Series(index=prices1.index, dtype=float)

    for i in range(window, len(prices1)):
        _, p_val, _ = coint(
            prices1.iloc[i-window:i],
            prices2.iloc[i-window:i]
        )
        p_values.iloc[i] = p_val

    return p_values

# 사용 예시: p-value가 0.05 초과 시 거래 중단
rolling_p = rolling_cointegration(prices_a, prices_b)
if rolling_p.iloc[-1] > 0.05:
    print("⚠️ 공적분 관계 약화 — 신규 진입 중단")
    close_all_positions()

실무에서는 롤링 공적분 p-value가 3회 연속 0.10을 초과하면 해당 페어의 거래를 전면 중단하고, 새로운 페어를 탐색하는 것이 안전합니다.

칼만 필터를 활용한 동적 헤지 비율

고정된 헤지 비율 대신 칼만 필터(Kalman Filter)로 실시간으로 변화하는 최적 헤지 비율을 추적하면 전략 성과를 크게 개선할 수 있습니다.

def kalman_hedge_ratio(prices1: pd.Series,
                       prices2: pd.Series) -> pd.DataFrame:
    """칼만 필터 기반 동적 헤지 비율 추정"""
    delta = 1e-4  # 상태 전이 공분산
    n = len(prices1)

    # 초기화
    beta = np.zeros(n)       # 헤지 비율
    P = np.zeros(n)          # 추정 오차 공분산
    beta[0] = 0
    P[0] = 1
    R = 1e-3                 # 관측 노이즈

    for t in range(1, n):
        # 예측 단계
        beta_pred = beta[t-1]
        P_pred = P[t-1] + delta

        # 업데이트 단계
        x = prices2.iloc[t]
        y = prices1.iloc[t]
        y_pred = beta_pred * x
        error = y - y_pred

        S = P_pred * x**2 + R
        K = P_pred * x / S  # 칼만 이득

        beta[t] = beta_pred + K * error
        P[t] = (1 - K * x) * P_pred

    return pd.DataFrame({
        'hedge_ratio': beta,
        'spread': prices1.values - beta * prices2.values
    }, index=prices1.index)

칼만 필터의 장점은 시장 구조 변화에 자동으로 적응한다는 것입니다. OLS 회귀는 과거 전체 데이터를 동일한 가중치로 반영하지만, 칼만 필터는 최근 데이터에 더 민감하게 반응하여 변화하는 헤지 비율을 실시간으로 추적합니다.

리스크 관리와 포트폴리오 구성

페어 트레이딩의 리스크를 체계적으로 관리하기 위한 핵심 기법들입니다.

다중 페어 분산 투자

단일 페어에 집중 투자하면 공적분 관계가 깨질 때 큰 손실을 입습니다. 5~10개 페어에 분산하여 개별 페어 리스크를 줄이세요.

def allocate_pairs(pairs: list, total_capital: float,
                   max_per_pair: float = 0.15) -> dict:
    """페어별 자본 배분 (최대 15% 제한)"""
    n = len(pairs)
    equal_weight = total_capital / n
    max_amount = total_capital * max_per_pair

    allocation = {}
    for pair in pairs:
        key = f"{pair['stock1']}/{pair['stock2']}"
        # 공적분 p-value가 낮을수록 더 많이 배분
        confidence = 1 - pair['p_value']
        amount = min(equal_weight * confidence * 2, max_amount)
        allocation[key] = round(amount)

    return allocation

최대 손실 한도 설정

  • 페어별 손절: 개별 페어에서 투자금의 5% 이상 손실 시 강제 청산
  • 포트폴리오 손절: 전체 자본의 10% 이상 손실 시 모든 포지션 청산 후 전략 재검토
  • 일일 손실 한도: 하루 손실이 자본의 2%를 초과하면 당일 신규 진입 중단

페어 트레이딩의 한계와 보완

실전에서 주의해야 할 핵심 리스크입니다:

  • 공적분 붕괴 리스크: 기업 합병, 산업 구조 변화 등으로 두 종목의 관계가 영구적으로 깨질 수 있습니다. 롤링 공적분 검정으로 상시 모니터링이 필수입니다.
  • 공매도 제약: 한국 시장에서는 개인 투자자의 공매도가 제한적입니다. 인버스 ETF 활용이나 선물/옵션을 통한 합성 공매도 포지션을 고려해야 합니다.
  • 실행 리스크: 두 종목을 동시에 매매해야 하므로 단일 종목 자동매매보다 실행 복잡도가 높습니다. 한쪽만 체결되는 레그 리스크(leg risk)에 대비해야 합니다.
  • 자본 효율: 마켓 뉴트럴 전략이므로 수익률이 방향성 전략보다 낮을 수 있습니다. 대신 변동성과 최대 낙폭이 훨씬 작아 위험 대비 수익(샤프 비율)은 우수합니다.

실시간 자동매매로 확장하기

백테스팅에서 검증된 전략을 실시간으로 전환할 때 필요한 추가 구성요소입니다:

# 실시간 페어 트레이딩 루프 (의사 코드)
import schedule

def pairs_trading_loop():
    """주기적 실행: 스프레드 모니터링 + 매매"""
    for pair in active_pairs:
        # 1. 최신 가격 수집
        p1 = broker.get_price(pair.stock1)
        p2 = broker.get_price(pair.stock2)

        # 2. 스프레드·Z-Score 업데이트
        z = update_zscore(pair, p1, p2)

        # 3. 공적분 건전성 체크 (매일 1회)
        if is_daily_check_time():
            p_val = check_cointegration(pair)
            if p_val > 0.10:
                close_pair(pair)
                alert(f"⚠️ {pair} 공적분 약화, 포지션 청산")
                continue

        # 4. 매매 실행
        if abs(z) > Z_ENTRY and not pair.has_position:
            open_pair_trade(pair, z)
        elif abs(z) < Z_EXIT and pair.has_position:
            close_pair(pair)
        elif abs(z) > Z_STOP and pair.has_position:
            close_pair(pair)
            alert(f"🛑 {pair} 손절 실행: Z={z:.2f}")

schedule.every(5).minutes.do(pairs_trading_loop)

마무리: 페어 트레이딩 실전 체크리스트

페어 트레이딩 자동매매 시스템을 운영하기 전 확인해야 할 핵심 항목입니다:

  • ✅ 공적분 검정(p-value < 0.05)으로 페어를 선정했는가?
  • ✅ 같은 섹터/산업 내에서 경제적 논리가 있는 페어인가?
  • ✅ 롤링 공적분 검정으로 관계의 안정성을 확인했는가?
  • ✅ 헤지 비율을 동적으로 업데이트(칼만 필터 등)하는가?
  • ✅ Z-Score 진입·청산·손절 임계값을 최적화했는가?
  • ✅ 5개 이상의 페어에 분산 투자하는가?
  • ✅ 공매도 제약과 레그 리스크 대비책이 있는가?
  • ✅ 거래 비용을 반영한 백테스팅을 수행했는가?

페어 트레이딩은 단일 종목 방향성 매매와 달리 통계적 우위(statistical edge)에 기반한 체계적 접근입니다. 시장 방향에 무관하게 수익을 추구할 수 있으며, 적절한 리스크 관리와 함께 운영하면 장기적으로 안정적인 성과를 기대할 수 있습니다. 공적분 분석의 기초를 탄탄히 다지고, 소규모 자본으로 실전 테스트를 거친 후 점진적으로 확장해 나가세요.

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